diff --git a/CLAUDE.md b/CLAUDE.md index e3cf4e6..b05eb65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -99,10 +99,21 @@ const result = grid_row * GRID_COLS + grid_col; // usize, works correctly - When hoisting shared locals (e.g., `cursor`) to wider scopes inside long functions, avoid re-declaring them later with the same name. Zig treats this as shadowing and fails compilation. Prefer a single binding per logical value or choose distinct names for nested scopes to prevent “local constant shadows” errors. ### Zig 0.15 collection API differences -- `std.ArrayList(T)` now prefers `initCapacity(allocator, 0)`; `append` and similar methods require the allocator argument (`list.append(allocator, item)`). +- `std.ArrayList(T)`: Zig 0.15 only provides `initCapacity(allocator, n)`, not `init()`. When initializing, use a reasonable capacity estimate (e.g., 8 or 16) rather than 0—`initCapacity(allocator, 0)` still allocates and is wasteful. For truly lazy allocation, use `.empty` and pass the allocator on each operation. Methods like `append` require the allocator argument (`list.append(allocator, item)`). - `std.fmt.allocPrintZ` is unavailable; create a null-terminated buffer manually: allocate `len+1`, copy bytes, set the last byte to 0, and slice as `buf[0..len :0]`. - For writers, `toml.serialize` expects `*std.Io.Writer`. Use `std.Io.Writer.Allocating.init(allocator)` (or `initCapacity`) and pass `&writer.writer`; read bytes with `writer.written()`. +### Loop boundary conditions +When iterating over slices with index arithmetic, use `< len` not `<= len`: +```zig +// WRONG: iterates one past the end, causing unnecessary work or out-of-bounds +while (pos <= slice.len) { ... } + +// CORRECT: stops at the last valid position +while (pos < slice.len) { ... } +``` +The `<= len` pattern is only correct when `pos` represents a position *after* processing (e.g., `slice[0..pos]` as a "processed so far" marker). + ## Build and Test (required after every task) - Run `zig build` and `zig build test` (or `just ci` when appropriate) once the task is complete. - Report the results in your summary; if you must skip tests, state the reason explicitly. diff --git a/README.md b/README.md index e7fc4ba..4d7a9cb 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,13 @@ Architect solves this with a grid view that keeps all your agents visible, with ### Agent-Focused - **Status highlights** — agents glow when awaiting approval or done, so you never miss a prompt -- **Grid view** — keep 4+ agents visible simultaneously, expand any one to full screen +- **Dynamic grid** — starts with a single terminal in full view; press ⌘N to add a terminal after the current one, and closing terminals compacts the grid forward +- **Grid view** — keep all agents visible simultaneously, expand any one to full screen - **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches ### Terminal Essentials -- Smooth expand/collapse animations between grid and focused views -- Keyboard navigation: ⌘+Return to expand, ⌘1–⌘0 to switch, ⌘W to close, ⌘/ for shortcuts +- Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together) +- Keyboard navigation: ⌘+Return to expand, ⌘1–⌘0 to switch grid slots, ⌘N to add, ⌘W to close (restarts if it's the only terminal), ⌘/ for shortcuts - Scrollback with trackpad/wheel support and grid indicator when scrolled - OSC 8 hyperlink support (Cmd+Click to open) - Kitty keyboard protocol for enhanced key handling @@ -107,9 +108,9 @@ just build Architect stores configuration in `~/.config/architect/`: * `config.toml`: read-only user preferences (edit via `⌘,`). -* `persistence.toml`: runtime state (window position/size, font size), managed automatically. +* `persistence.toml`: runtime state (window position/size, font size, terminal cwds), managed automatically. -Common settings include font family, theme colors, and grid rows/cols. Remove the files to reset to the default values. +Common settings include font family, theme colors, and grid font scale. The grid size is dynamic and adapts to the number of terminals. Remove the files to reset to the default values. ## Troubleshooting diff --git a/docs/architecture.md b/docs/architecture.md index 997ff02..2a475b9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture Overview -Architect is a terminal multiplexer displaying interactive sessions in a grid with smooth expand/collapse animations. It is organized around five layers: platform abstraction, input handling, session management, scene rendering, and a UI overlay system. +Architect is a terminal multiplexer displaying interactive sessions in a grid with smooth expand/collapse and resize/reflow animations. It is organized around five layers: platform abstraction, input handling, session management, scene rendering, and a UI overlay system. ``` ┌─────────────────────────────────────────────────────────────┐ @@ -62,6 +62,8 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi - Reports `needsFrame()` when any component requires animation - Owns per-session `SessionViewState` via `SessionInteractionComponent` (selection, hover, scrollback state) +Grid slots are ordered independently of session IDs: slot indices drive UI focus and shortcuts, while each `SessionState` receives a monotonic `id` used for external notifications. Slots may be compacted without changing session IDs. Grid resize animations are canceled when the last terminal is relaunched so the renderer does not keep animating stale cell geometry, and runtime logs (including short frame traces) capture the close/relaunch path for debugging. + **UiAssets** provides shared rendering resources: - `FontCache` stores configured fonts keyed by pixel size, so terminal rendering and UI components reuse a single loaded font set instead of opening per-component instances. @@ -163,7 +165,7 @@ src/ ### View Modes (`app_state.ViewMode`) ``` -Grid → 3×3 overview, all sessions visible +Grid → Dynamic grid overview, all sessions visible Expanding → Animating from grid cell to fullscreen Full → Single session fullscreen Collapsing → Animating from fullscreen to grid cell @@ -171,6 +173,7 @@ PanningLeft → Horizontal pan animation (moving left) PanningRight → Horizontal pan animation (moving right) PanningUp → Vertical pan animation (moving up) PanningDown → Vertical pan animation (moving down) +GridResizing → Grid is expanding or shrinking (adding/removing cells) ``` ### Session Status (`app_state.SessionStatus`) diff --git a/docs/configuration.md b/docs/configuration.md index e32a555..aa65b8b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,12 +31,15 @@ The font family must be installed on your system. Common choices: ```toml [grid] -rows = 3 # Number of rows (1-12, default: 3) -cols = 3 # Number of columns (1-12, default: 3) font_scale = 1.0 # Font scale in grid view (0.5-3.0, default: 1.0) ``` -The grid defines how many terminal sessions are displayed. Values outside the valid range are clamped automatically. +The grid size is dynamic and adjusts automatically based on the number of terminals: +- Press `Cmd+N` to add a new terminal after the currently focused one — the grid expands to accommodate it +- Press `Cmd+W` to close a terminal — remaining terminals compact forward to fill gaps and the grid shrinks when possible; if it's the only terminal, it restarts in place +- When only one terminal is spawned, the view stays in full-screen mode +- Grid layout maintains `columns >= rows` (e.g., 1x1 → 2x1 → 2x2 → 3x2 → 3x3 → ...) +- Maximum grid size is 12×12 (144 terminals) ### Window Configuration @@ -145,8 +148,6 @@ family = "JetBrains Mono" size = 13 [grid] -rows = 2 -cols = 3 font_scale = 0.9 [theme] @@ -199,10 +200,11 @@ height = 900 x = 100 y = 50 -[terminals] -terminal_1_1 = "/Users/me/projects/app" -terminal_1_2 = "/Users/me/projects/lib" -terminal_2_1 = "/Users/me" +terminals = [ + "/Users/me/projects/app", + "/Users/me/projects/lib", + "/Users/me", +] ``` ### Fields @@ -211,18 +213,14 @@ terminal_2_1 = "/Users/me" |-------|-------------| | `font_size` | Current font size (adjusted with `Cmd++`/`Cmd+-`) | | `[window]` | Last window position and dimensions | -| `[terminals]` | Working directories for each terminal cell | +| `terminals` | Working directories for each terminal (ordered by session index) | -### Terminal Keys - -Terminal keys use 1-based `row_col` format: -- `terminal_1_1` = top-left cell (row 1, column 1) -- `terminal_2_3` = second row, third column - -On launch, Architect restores terminals to their saved working directories. Entries outside the current grid dimensions are pruned automatically. +On launch, Architect restores terminals to their saved working directories. The grid automatically resizes to fit the number of restored terminals. Note: Terminal cwd persistence is currently macOS-only. +Older `persistence.toml` files that used the `[terminals]` table are migrated automatically. + ## Resetting Configuration Delete the configuration files to reset to defaults: @@ -237,4 +235,3 @@ Or remove the entire directory: ```bash rm -rf ~/.config/architect ``` - diff --git a/src/app/app_state.zig b/src/app/app_state.zig index 132ae6d..61463d7 100644 --- a/src/app/app_state.zig +++ b/src/app/app_state.zig @@ -20,6 +20,7 @@ pub const ViewMode = enum { PanningRight, PanningUp, PanningDown, + GridResizing, // Grid is reflowing or changing dimensions (adding/removing cells) }; pub const Rect = geom.Rect; diff --git a/src/app/grid_layout.zig b/src/app/grid_layout.zig new file mode 100644 index 0000000..62e663c --- /dev/null +++ b/src/app/grid_layout.zig @@ -0,0 +1,306 @@ +// Dynamic grid layout management for the terminal wall. +// Handles automatic grid expansion/contraction as terminals are added/removed. +const std = @import("std"); +const geom = @import("../geom.zig"); +const easing = @import("../anim/easing.zig"); + +const Rect = geom.Rect; +const log = std.log.scoped(.grid_layout); + +pub const MAX_GRID_SIZE: usize = 12; +pub const MAX_TERMINALS: usize = MAX_GRID_SIZE * MAX_GRID_SIZE; + +/// Represents a position in the grid (column, row). +pub const GridPosition = struct { + col: usize, + row: usize, + + pub fn toIndex(self: GridPosition, cols: usize) usize { + return self.row * cols + self.col; + } + + pub fn fromIndex(idx: usize, cols: usize) GridPosition { + return .{ + .col = idx % cols, + .row = idx / cols, + }; + } +}; + +/// Animation state for a terminal moving between grid positions. +pub const TerminalAnimation = struct { + session_idx: usize, + start_rect: Rect, + target_rect: Rect, + start_time: i64, +}; + +/// Describes how a session moves when the grid layout changes. +pub const SessionMove = struct { + session_idx: usize, + old_index: ?usize, +}; + +/// Manages dynamic grid dimensions based on active terminal count. +pub const GridLayout = struct { + cols: usize, + rows: usize, + /// Animations for terminals moving during grid resize. + animations: std.ArrayList(TerminalAnimation), + /// Timestamp when grid resize animation started. + resize_start_time: i64, + /// Previous dimensions before resize (for animation). + prev_cols: usize, + prev_rows: usize, + /// Whether a grid resize animation is in progress. + is_resizing: bool, + allocator: std.mem.Allocator, + + pub const ANIMATION_DURATION_MS: i64 = 300; + + pub fn init(allocator: std.mem.Allocator) !GridLayout { + return .{ + .cols = 1, + .rows = 1, + .animations = try std.ArrayList(TerminalAnimation).initCapacity(allocator, 16), + .resize_start_time = 0, + .prev_cols = 1, + .prev_rows = 1, + .is_resizing = false, + .allocator = allocator, + }; + } + + pub fn deinit(self: *GridLayout) void { + self.animations.deinit(self.allocator); + } + + /// Calculate optimal grid dimensions for a given terminal count. + /// Maintains cols >= rows invariant. + pub fn calculateDimensions(count: usize) struct { cols: usize, rows: usize } { + if (count == 0) return .{ .cols = 1, .rows = 1 }; + if (count == 1) return .{ .cols = 1, .rows = 1 }; + if (count == 2) return .{ .cols = 2, .rows = 1 }; + + // Find smallest grid where cols >= rows and cols * rows >= count + var rows: usize = 1; + while (rows <= MAX_GRID_SIZE) : (rows += 1) { + // Start with square, then try cols = rows + 1 + var cols = rows; + while (cols <= MAX_GRID_SIZE and cols <= rows + 1) : (cols += 1) { + if (cols * rows >= count) { + return .{ .cols = cols, .rows = rows }; + } + } + } + + return .{ .cols = MAX_GRID_SIZE, .rows = MAX_GRID_SIZE }; + } + + /// Returns the total capacity of the current grid. + pub fn capacity(self: *const GridLayout) usize { + return self.cols * self.rows; + } + + /// Check if the grid needs to expand to fit one more terminal. + pub fn needsExpansion(self: *const GridLayout, active_count: usize) bool { + return active_count >= self.capacity(); + } + + /// Check if the grid can shrink after removing a terminal. + pub fn canShrink(self: *const GridLayout, active_count: usize) bool { + if (active_count == 0) return self.cols > 1 or self.rows > 1; + const optimal = calculateDimensions(active_count); + return optimal.cols < self.cols or optimal.rows < self.rows; + } + + /// Convert session index to grid position. + pub fn indexToPosition(self: *const GridLayout, idx: usize) GridPosition { + return GridPosition.fromIndex(idx, self.cols); + } + + /// Convert grid position to session index. + pub fn positionToIndex(self: *const GridLayout, pos: GridPosition) usize { + return pos.toIndex(self.cols); + } + + /// Calculate pixel rect for a grid cell. + pub fn cellRect( + self: *const GridLayout, + pos: GridPosition, + render_width: c_int, + render_height: c_int, + ) Rect { + const cell_w = @divFloor(render_width, @as(c_int, @intCast(self.cols))); + const cell_h = @divFloor(render_height, @as(c_int, @intCast(self.rows))); + return Rect{ + .x = @as(c_int, @intCast(pos.col)) * cell_w, + .y = @as(c_int, @intCast(pos.row)) * cell_h, + .w = cell_w, + .h = cell_h, + }; + } + + /// Start a grid resize animation. + pub fn startResize( + self: *GridLayout, + new_cols: usize, + new_rows: usize, + now: i64, + render_width: c_int, + render_height: c_int, + session_moves: []const SessionMove, + ) !void { + self.animations.clearRetainingCapacity(); + log.debug("start resize {d}x{d} -> {d}x{d} moves={d}", .{ + self.cols, + self.rows, + new_cols, + new_rows, + session_moves.len, + }); + self.prev_cols = self.cols; + self.prev_rows = self.rows; + self.resize_start_time = now; + + // Calculate where each active session will move from/to + const old_cell_w = @divFloor(render_width, @as(c_int, @intCast(self.cols))); + const old_cell_h = @divFloor(render_height, @as(c_int, @intCast(self.rows))); + const new_cell_w = @divFloor(render_width, @as(c_int, @intCast(new_cols))); + const new_cell_h = @divFloor(render_height, @as(c_int, @intCast(new_rows))); + + for (session_moves) |move| { + const new_pos = GridPosition.fromIndex(move.session_idx, new_cols); + + const target_rect = Rect{ + .x = @as(c_int, @intCast(new_pos.col)) * new_cell_w, + .y = @as(c_int, @intCast(new_pos.row)) * new_cell_h, + .w = new_cell_w, + .h = new_cell_h, + }; + + const start_rect = if (move.old_index) |old_idx| blk: { + const old_pos = GridPosition.fromIndex(old_idx, self.cols); + break :blk Rect{ + .x = @as(c_int, @intCast(old_pos.col)) * old_cell_w, + .y = @as(c_int, @intCast(old_pos.row)) * old_cell_h, + .w = old_cell_w, + .h = old_cell_h, + }; + } else target_rect; + + try self.animations.append(self.allocator, .{ + .session_idx = move.session_idx, + .start_rect = start_rect, + .target_rect = target_rect, + .start_time = now, + }); + } + + self.cols = new_cols; + self.rows = new_rows; + self.is_resizing = true; + } + + /// Update resize animation state. Returns true if animation is complete. + pub fn updateResize(self: *GridLayout, now: i64) bool { + if (!self.is_resizing) return true; + + const elapsed = now - self.resize_start_time; + if (elapsed >= ANIMATION_DURATION_MS) { + self.is_resizing = false; + self.animations.clearRetainingCapacity(); + return true; + } + return false; + } + + pub fn cancelResize(self: *GridLayout) void { + log.debug("cancel resize {d}x{d} anims={d}", .{ self.cols, self.rows, self.animations.items.len }); + self.is_resizing = false; + self.resize_start_time = 0; + self.animations.clearRetainingCapacity(); + } + + /// Get the current animated rect for a session during resize. + pub fn getAnimatedRect( + self: *const GridLayout, + session_idx: usize, + now: i64, + ) ?Rect { + if (!self.is_resizing) return null; + + for (self.animations.items) |anim| { + if (anim.session_idx == session_idx) { + const elapsed = now - anim.start_time; + const progress = @min(1.0, @as(f32, @floatFromInt(elapsed)) / @as(f32, ANIMATION_DURATION_MS)); + const eased = easing.easeInOutCubic(progress); + return interpolateRect(anim.start_rect, anim.target_rect, eased); + } + } + + // Session wasn't in the animation list - it's a new cell + return null; + } + + /// Get animation progress (0.0 to 1.0). + pub fn getResizeProgress(self: *const GridLayout, now: i64) f32 { + if (!self.is_resizing) return 1.0; + const elapsed = now - self.resize_start_time; + return @min(1.0, @as(f32, @floatFromInt(elapsed)) / @as(f32, ANIMATION_DURATION_MS)); + } + + fn interpolateRect(start: Rect, target: Rect, progress: f32) Rect { + return Rect{ + .x = start.x + @as(c_int, @intFromFloat(@as(f32, @floatFromInt(target.x - start.x)) * progress)), + .y = start.y + @as(c_int, @intFromFloat(@as(f32, @floatFromInt(target.y - start.y)) * progress)), + .w = start.w + @as(c_int, @intFromFloat(@as(f32, @floatFromInt(target.w - start.w)) * progress)), + .h = start.h + @as(c_int, @intFromFloat(@as(f32, @floatFromInt(target.h - start.h)) * progress)), + }; + } +}; + +test "calculateDimensions" { + // 0-1 terminals: 1x1 + try std.testing.expectEqual(@as(usize, 1), GridLayout.calculateDimensions(0).cols); + try std.testing.expectEqual(@as(usize, 1), GridLayout.calculateDimensions(0).rows); + try std.testing.expectEqual(@as(usize, 1), GridLayout.calculateDimensions(1).cols); + try std.testing.expectEqual(@as(usize, 1), GridLayout.calculateDimensions(1).rows); + + // 2 terminals: 2x1 + try std.testing.expectEqual(@as(usize, 2), GridLayout.calculateDimensions(2).cols); + try std.testing.expectEqual(@as(usize, 1), GridLayout.calculateDimensions(2).rows); + + // 3-4 terminals: 2x2 + try std.testing.expectEqual(@as(usize, 2), GridLayout.calculateDimensions(3).cols); + try std.testing.expectEqual(@as(usize, 2), GridLayout.calculateDimensions(3).rows); + try std.testing.expectEqual(@as(usize, 2), GridLayout.calculateDimensions(4).cols); + try std.testing.expectEqual(@as(usize, 2), GridLayout.calculateDimensions(4).rows); + + // 5-6 terminals: 3x2 + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(5).cols); + try std.testing.expectEqual(@as(usize, 2), GridLayout.calculateDimensions(5).rows); + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(6).cols); + try std.testing.expectEqual(@as(usize, 2), GridLayout.calculateDimensions(6).rows); + + // 7-9 terminals: 3x3 + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(7).cols); + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(7).rows); + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(9).cols); + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(9).rows); + + // 10-12 terminals: 4x3 + try std.testing.expectEqual(@as(usize, 4), GridLayout.calculateDimensions(10).cols); + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(10).rows); + try std.testing.expectEqual(@as(usize, 4), GridLayout.calculateDimensions(12).cols); + try std.testing.expectEqual(@as(usize, 3), GridLayout.calculateDimensions(12).rows); +} + +test "GridPosition" { + const pos = GridPosition{ .col = 2, .row = 1 }; + try std.testing.expectEqual(@as(usize, 5), pos.toIndex(3)); + + const pos2 = GridPosition.fromIndex(5, 3); + try std.testing.expectEqual(@as(usize, 2), pos2.col); + try std.testing.expectEqual(@as(usize, 1), pos2.row); +} diff --git a/src/app/grid_nav.zig b/src/app/grid_nav.zig index ef437a8..257b93e 100644 --- a/src/app/grid_nav.zig +++ b/src/app/grid_nav.zig @@ -73,7 +73,7 @@ pub fn formatGridNotification(buf: []u8, focused_session: usize, grid_cols: usiz pub fn navigateGrid( anim_state: *AnimationState, - sessions: []SessionState, + sessions: []*SessionState, session_interaction: *ui_mod.SessionInteractionComponent, direction: input.GridNavDirection, now: i64, diff --git a/src/app/input_text.zig b/src/app/input_text.zig index bee58e3..42d89fc 100644 --- a/src/app/input_text.zig +++ b/src/app/input_text.zig @@ -55,12 +55,12 @@ pub fn handleTextEditing( const text = std.mem.sliceTo(text_ptr, 0); if (text.len == 0) { if (ime.codepoints == 0) return; - session_interaction.resetScrollIfNeeded(session.id); + session_interaction.resetScrollIfNeeded(session.slot_index); try clearImeComposition(session, ime); return; } - session_interaction.resetScrollIfNeeded(session.id); + session_interaction.resetScrollIfNeeded(session.slot_index); const is_committed_text = length == 0 and start == 0; if (is_committed_text) { try clearImeComposition(session, ime); @@ -85,7 +85,7 @@ pub fn handleTextInput( const text = std.mem.sliceTo(text_ptr, 0); if (text.len == 0) return; - session_interaction.resetScrollIfNeeded(session.id); + session_interaction.resetScrollIfNeeded(session.slot_index); try clearImeComposition(session, ime); try session.sendInput(text); } diff --git a/src/app/layout.zig b/src/app/layout.zig index 6ac62a8..c71fc29 100644 --- a/src/app/layout.zig +++ b/src/app/layout.zig @@ -70,7 +70,7 @@ pub fn calculateHoveredSession( grid_rows: usize, ) ?usize { return switch (anim_state.mode) { - .Grid => { + .Grid, .GridResizing => { if (mouse_x < 0 or mouse_x >= render_width or mouse_y < 0 or mouse_y >= render_height) return null; @@ -113,7 +113,7 @@ pub fn calculateGridCellTerminalSize(font: *const font_mod.Font, window_width: c pub fn calculateTerminalSizeForMode(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) TerminalSize { return switch (mode) { - .Grid, .Expanding, .Collapsing => { + .Grid, .Expanding, .Collapsing, .GridResizing => { 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; @@ -130,13 +130,13 @@ pub fn scaledFontSize(points: c_int, scale: f32) c_int { pub fn gridFontScaleForMode(mode: app_state.ViewMode, grid_font_scale: f32) f32 { return switch (mode) { - .Grid, .Expanding, .Collapsing => grid_font_scale, + .Grid, .Expanding, .Collapsing, .GridResizing => grid_font_scale, else => 1.0, }; } pub fn applyTerminalResize( - sessions: []SessionState, + sessions: []const *SessionState, allocator: std.mem.Allocator, cols: u16, rows: u16, @@ -153,7 +153,7 @@ pub fn applyTerminalResize( .ws_ypixel = @intCast(usable_height), }; - for (sessions) |*session| { + for (sessions) |session| { session.pty_size = new_size; if (session.spawned) { const shell = &(session.shell orelse continue); diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e821968..c0a708b 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -4,6 +4,7 @@ const std = @import("std"); const builtin = @import("builtin"); const xev = @import("xev"); const app_state = @import("app_state.zig"); +const grid_layout = @import("grid_layout.zig"); const grid_nav = @import("grid_nav.zig"); const input_keys = @import("input_keys.zig"); const input_text = @import("input_text.zig"); @@ -13,6 +14,7 @@ const ui_host = @import("ui_host.zig"); const worktree = @import("worktree.zig"); const notify = @import("../session/notify.zig"); const session_state = @import("../session/state.zig"); +const view_state = @import("../ui/session_view_state.zig"); const platform = @import("../platform/sdl.zig"); const macos_input = @import("../platform/macos_input_source.zig"); const input = @import("../input/mapper.zig"); @@ -45,13 +47,16 @@ const Rect = app_state.Rect; const AnimationState = app_state.AnimationState; const NotificationQueue = notify.NotificationQueue; const SessionState = session_state.SessionState; +const SessionViewState = view_state.SessionViewState; +const GridLayout = grid_layout.GridLayout; +const SessionMove = grid_layout.SessionMove; const ForegroundProcessCache = struct { session_idx: ?usize = null, last_check_ms: i64 = 0, value: bool = false, - fn get(self: *ForegroundProcessCache, now_ms: i64, focused_session: usize, sessions: []const SessionState) bool { + fn get(self: *ForegroundProcessCache, now_ms: i64, focused_session: usize, sessions: []const *SessionState) bool { if (self.session_idx != focused_session) { self.session_idx = focused_session; self.last_check_ms = 0; @@ -66,9 +71,9 @@ const ForegroundProcessCache = struct { } }; -fn countForegroundProcesses(sessions: []const SessionState) usize { +fn countForegroundProcesses(sessions: []const *SessionState) usize { var total: usize = 0; - for (sessions) |*session| { + for (sessions) |session| { if (session.hasForegroundProcess()) { total += 1; } @@ -76,16 +81,100 @@ fn countForegroundProcesses(sessions: []const SessionState) usize { return total; } -fn findNextFreeSession(sessions: []const SessionState, current_idx: usize) ?usize { - const start_idx = current_idx + 1; - var idx = start_idx; - while (idx < sessions.len) : (idx += 1) { - if (!sessions[idx].spawned) { - return idx; +fn countSpawnedSessions(sessions: []const *SessionState) usize { + var count: usize = 0; + for (sessions) |session| { + if (session.spawned) count += 1; + } + return count; +} + +fn highestSpawnedIndex(sessions: []const *SessionState) ?usize { + var idx: usize = sessions.len; + while (idx > 0) { + idx -= 1; + if (sessions[idx].spawned) return idx; + } + return null; +} + +const SessionIndexSnapshot = struct { + session_id: usize, + index: usize, +}; + +/// Collect indices for spawned sessions to preserve their pre-compaction positions. +fn collectSessionIndexSnapshots( + sessions: []const *SessionState, + allocator: std.mem.Allocator, +) !std.ArrayList(SessionIndexSnapshot) { + var snapshots = try std.ArrayList(SessionIndexSnapshot).initCapacity(allocator, 0); + for (sessions, 0..) |session, idx| { + if (session.spawned) { + try snapshots.append(allocator, .{ .session_id = session.id, .index = idx }); } } - idx = 0; - while (idx < start_idx) : (idx += 1) { + return snapshots; +} + +fn findSnapshotIndex(snapshots: []const SessionIndexSnapshot, session_id: usize) ?usize { + for (snapshots) |snapshot| { + if (snapshot.session_id == session_id) return snapshot.index; + } + return null; +} + +const SessionMoves = struct { + list: std.ArrayList(SessionMove), + moved: bool, +}; + +/// Collect session moves using the current indices as both old and new positions. +fn collectSessionMovesCurrent( + sessions: []const *SessionState, + allocator: std.mem.Allocator, +) !std.ArrayList(SessionMove) { + var moves = try std.ArrayList(SessionMove).initCapacity(allocator, 0); + for (sessions, 0..) |session, idx| { + if (session.spawned) { + try moves.append(allocator, .{ .session_idx = idx, .old_index = idx }); + } + } + return moves; +} + +/// Collect session moves using snapshot indices as old positions, returning whether any moved. +fn collectSessionMovesFromSnapshots( + sessions: []const *SessionState, + snapshots: []const SessionIndexSnapshot, + allocator: std.mem.Allocator, +) !SessionMoves { + var moves = try std.ArrayList(SessionMove).initCapacity(allocator, 0); + var moved = false; + for (sessions, 0..) |session, idx| { + if (!session.spawned) continue; + const old_index = findSnapshotIndex(snapshots, session.id); + if (old_index) |old_idx| { + if (old_idx != idx) moved = true; + } else { + moved = true; + } + try moves.append(allocator, .{ .session_idx = idx, .old_index = old_index }); + } + return .{ .list = moves, .moved = moved }; +} + +fn findNextFreeSlotAfter( + sessions: []const *SessionState, + grid_capacity: usize, + start_idx: usize, +) ?usize { + if (grid_capacity == 0) return null; + + var offset: usize = 1; + while (offset <= grid_capacity) : (offset += 1) { + const idx = (start_idx + offset) % grid_capacity; + if (idx >= sessions.len) continue; if (!sessions[idx].spawned) { return idx; } @@ -93,6 +182,81 @@ fn findNextFreeSession(sessions: []const SessionState, current_idx: usize) ?usiz return null; } +fn findSessionIndexById(sessions: []const *SessionState, session_id: usize) ?usize { + for (sessions, 0..) |session, idx| { + if (session.spawned and session.id == session_id) return idx; + } + return null; +} + +fn compactSessions( + sessions: []*SessionState, + views: []SessionViewState, + render_cache: *renderer_mod.RenderCache, + anim_state: *AnimationState, +) void { + const focused_id: ?usize = if (anim_state.focused_session < sessions.len and sessions[anim_state.focused_session].spawned) + sessions[anim_state.focused_session].id + else + null; + const previous_id: ?usize = if (anim_state.previous_session < sessions.len and sessions[anim_state.previous_session].spawned) + sessions[anim_state.previous_session].id + else + null; + + var write_idx: usize = 0; + var idx: usize = 0; + while (idx < sessions.len) : (idx += 1) { + if (!sessions[idx].spawned) continue; + if (write_idx != idx) { + std.mem.swap(*SessionState, &sessions[write_idx], &sessions[idx]); + std.mem.swap(SessionViewState, &views[write_idx], &views[idx]); + std.mem.swap(renderer_mod.RenderCache.Entry, &render_cache.entries[write_idx], &render_cache.entries[idx]); + } + write_idx += 1; + } + + for (sessions, 0..) |session, slot_idx| { + session.slot_index = slot_idx; + } + + if (focused_id) |id| { + if (findSessionIndexById(sessions, id)) |new_idx| { + anim_state.focused_session = new_idx; + } + } + if (previous_id) |id| { + if (findSessionIndexById(sessions, id)) |new_idx| { + anim_state.previous_session = new_idx; + } + } +} + +const WorkingDir = struct { + cwd_z: ?[:0]const u8, + buf: ?[]u8, + + fn init(allocator: std.mem.Allocator, cwd_path: ?[]const u8) WorkingDir { + var buf: ?[]u8 = null; + const cwd_z: ?[:0]const u8 = if (cwd_path) |path| blk: { + const owned = allocator.alloc(u8, path.len + 1) catch break :blk null; + @memcpy(owned[0..path.len], path); + owned[path.len] = 0; + buf = owned; + break :blk owned[0..path.len :0]; + } else null; + + return .{ + .cwd_z = cwd_z, + .buf = buf, + }; + } + + fn deinit(self: *WorkingDir, allocator: std.mem.Allocator) void { + if (self.buf) |buf| allocator.free(buf); + } +}; + fn initSharedFont( allocator: std.mem.Allocator, renderer: *c.SDL_Renderer, @@ -114,7 +278,7 @@ fn initSharedFont( } fn handleQuitRequest( - sessions: []const SessionState, + sessions: []const *SessionState, confirm: *ui_mod.quit_confirm.QuitConfirmComponent, ) bool { const running_processes = countForegroundProcesses(sessions); @@ -160,10 +324,6 @@ pub fn run() !void { .width = INITIAL_WINDOW_WIDTH, .height = INITIAL_WINDOW_HEIGHT, }, - .grid = .{ - .rows = config_mod.DEFAULT_GRID_ROWS, - .cols = config_mod.DEFAULT_GRID_COLS, - }, }; }; defer config.deinit(allocator); @@ -175,31 +335,26 @@ pub fn run() !void { fallback.window = config.window; break :blk fallback; }; - defer persistence.deinit(); + defer persistence.deinit(allocator); persistence.font_size = std.math.clamp(persistence.font_size, MIN_FONT_SIZE, MAX_FONT_SIZE); const theme = colors_mod.Theme.fromConfig(config.theme); - const grid_rows: usize = @intCast(config.grid.rows); - const grid_cols: usize = @intCast(config.grid.cols); - const grid_count: usize = grid_rows * grid_cols; - const pruned_terminals = persistence.pruneTerminals(allocator, grid_cols, grid_rows) catch |err| blk: { - std.debug.print("Failed to prune persisted terminals: {}\n", .{err}); - break :blk false; - }; - if (pruned_terminals) { - persistence.save(allocator) catch |err| { - std.debug.print("Failed to save pruned persistence: {}\n", .{err}); - }; - } - var restored_terminals = if (builtin.os.tag == .macos) - persistence.collectTerminalEntries(allocator, grid_cols, grid_rows) catch |err| blk: { - std.debug.print("Failed to collect persisted terminals: {}\n", .{err}); - break :blk std.ArrayList(config_mod.Persistence.TerminalEntry).empty; - } - else - std.ArrayList(config_mod.Persistence.TerminalEntry).empty; - defer restored_terminals.deinit(allocator); + // Dynamic grid layout - starts with 1x1 and grows as terminals are added + var grid = try GridLayout.init(allocator); + defer grid.deinit(); + + // Load persisted terminals to determine initial grid size + const restored_paths = if (builtin.os.tag == .macos) persistence.terminal_paths.items else &[_][]const u8{}; + const restored_limit = @min(restored_paths.len, grid_layout.MAX_TERMINALS); + const restored_slice = restored_paths[0..restored_limit]; + + // Calculate initial grid size based on restored terminals + const initial_terminal_count: usize = if (restored_slice.len > 0) restored_slice.len else 1; + const initial_dims = GridLayout.calculateDimensions(initial_terminal_count); + grid.cols = initial_dims.cols; + grid.rows = initial_dims.rows; + var current_grid_font_scale: f32 = config.grid.font_scale; const animations_enabled = config.ui.enable_animations; @@ -271,17 +426,17 @@ pub fn run() !void { var window_x: c_int = persistence.window.x; var window_y: c_int = persistence.window.y; - const initial_term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, .Grid, config.grid.font_scale, grid_cols, grid_rows); + const initial_term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, .Grid, config.grid.font_scale, grid.cols, grid.rows); var full_cols: u16 = initial_term_size.cols; var full_rows: u16 = initial_term_size.rows; std.debug.print("Grid cell terminal size: {d}x{d}\n", .{ full_cols, full_rows }); const shell_path = std.posix.getenv("SHELL") orelse "/bin/zsh"; - std.debug.print("Spawning {d} shell instances ({d}x{d} grid): {s}\n", .{ grid_count, grid_cols, grid_rows, shell_path }); + std.debug.print("Starting with {d}x{d} grid: {s}\n", .{ grid.cols, grid.rows, shell_path }); - var cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid_cols))); - var cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid_rows))); + var cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); + var cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); const usable_width = @max(0, render_width - renderer_mod.TERMINAL_PADDING * 2); const usable_height = @max(0, render_height - renderer_mod.TERMINAL_PADDING * 2); @@ -293,57 +448,63 @@ pub fn run() !void { .ws_ypixel = @intCast(usable_height), }; - const sessions = try allocator.alloc(SessionState, grid_count); + // Allocate max possible sessions to avoid reallocation + const sessions_storage = try allocator.alloc(SessionState, grid_layout.MAX_TERMINALS); + const sessions = try allocator.alloc(*SessionState, grid_layout.MAX_TERMINALS); var init_count: usize = 0; defer { var i: usize = 0; while (i < init_count) : (i += 1) { - sessions[i].deinit(allocator); + sessions_storage[i].deinit(allocator); } + allocator.free(sessions_storage); allocator.free(sessions); } var loop = try xev.Loop.init(.{}); defer loop.deinit(); - for (0..grid_count) |i| { - var session_buf: [16]u8 = undefined; - const session_z = try std.fmt.bufPrintZ(&session_buf, "{d}", .{i}); - sessions[i] = try SessionState.init(allocator, i, shell_path, size, session_z, notify_sock); + // Initialize all session slots + for (0..grid_layout.MAX_TERMINALS) |i| { + sessions_storage[i] = try SessionState.init(allocator, i, shell_path, size, notify_sock); + sessions[i] = &sessions_storage[i]; init_count += 1; } - for (restored_terminals.items) |entry| { - if (entry.index >= sessions.len or entry.path.len == 0) continue; - const dir_buf = allocZ(allocator, entry.path) catch |err| blk: { - std.debug.print("Failed to restore terminal {d}: {}\n", .{ entry.index, err }); + // Restore persisted terminals + for (restored_slice, 0..) |path, new_idx| { + if (new_idx >= sessions.len or path.len == 0) continue; + const dir_buf = allocZ(allocator, path) catch |err| blk: { + std.debug.print("Failed to restore terminal {d}: {}\n", .{ new_idx, err }); break :blk null; }; defer if (dir_buf) |buf| allocator.free(buf); if (dir_buf) |buf| { - const dir: [:0]const u8 = buf[0..entry.path.len :0]; - sessions[entry.index].ensureSpawnedWithDir(dir, &loop) catch |err| { - std.debug.print("Failed to spawn restored terminal {d}: {}\n", .{ entry.index, err }); + const dir: [:0]const u8 = buf[0..path.len :0]; + sessions[new_idx].ensureSpawnedWithDir(dir, &loop) catch |err| { + std.debug.print("Failed to spawn restored terminal {d}: {}\n", .{ new_idx, err }); }; } } + // Always spawn at least the first terminal try sessions[0].ensureSpawnedWithLoop(&loop); init_count = sessions.len; - const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); + const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_layout.MAX_TERMINALS); defer allocator.free(session_ui_info); - var render_cache = try renderer_mod.RenderCache.init(allocator, grid_count); + var render_cache = try renderer_mod.RenderCache.init(allocator, grid_layout.MAX_TERMINALS); defer render_cache.deinit(); var foreground_cache = ForegroundProcessCache{}; var running = true; + const initial_mode: app_state.ViewMode = if (countSpawnedSessions(sessions) == 1) .Full else .Grid; var anim_state = AnimationState{ - .mode = .Grid, + .mode = initial_mode, .focused_session = 0, .previous_session = 0, .start_time = 0, @@ -352,6 +513,7 @@ pub fn run() !void { }; var ime_composition = input_text.ImeComposition{}; var last_focused_session: usize = anim_state.focused_session; + var relaunch_trace_frames: u8 = 0; const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font); try ui.register(session_interaction_component.asComponent()); @@ -403,13 +565,21 @@ pub fn run() !void { while (running) { const frame_start_ns: i128 = std.time.nanoTimestamp(); const now = std.time.milliTimestamp(); + if (relaunch_trace_frames > 0) { + log.info("frame trace start mode={s} grid_resizing={} grid={d}x{d}", .{ + @tagName(anim_state.mode), + grid.is_resizing, + grid.cols, + grid.rows, + }); + } var event: c.SDL_Event = undefined; var processed_event = false; while (c.SDL_PollEvent(&event)) { if (anim_state.focused_session != last_focused_session) { const previous_session = last_focused_session; - input_text.clearImeComposition(&sessions[previous_session], &ime_composition) catch |err| { + input_text.clearImeComposition(sessions[previous_session], &ime_composition) catch |err| { std.debug.print("Failed to clear IME composition: {}\n", .{err}); }; ime_composition.reset(); @@ -425,8 +595,8 @@ pub fn run() !void { ui_scale, cell_width_pixels, cell_height_pixels, - grid_cols, - grid_rows, + grid.cols, + grid.rows, full_cols, full_rows, &anim_state, @@ -468,18 +638,18 @@ pub fn run() !void { font.metrics = metrics_ptr; ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, layout.scaledFontSize(UI_FONT_SIZE, ui_scale)); ui.assets.ui_font = &ui_font; - const new_term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows); + const new_term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid.cols, grid.rows); full_cols = new_term_size.cols; full_rows = new_term_size.rows; layout.applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); } else { - const new_term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows); + const new_term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid.cols, grid.rows); full_cols = new_term_size.cols; full_rows = new_term_size.rows; layout.applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); } - cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid_cols))); - cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid_rows))); + cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); + cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); std.debug.print("Window resized to: {d}x{d} (render {d}x{d}), terminal size: {d}x{d}\n", .{ window_width_points, window_height_points, render_width, render_height, full_cols, full_rows }); @@ -521,13 +691,13 @@ pub fn run() !void { } }, c.SDL_EVENT_TEXT_INPUT => { - const focused = &sessions[anim_state.focused_session]; + const focused = sessions[anim_state.focused_session]; input_text.handleTextInput(focused, &ime_composition, scaled_event.text.text, session_interaction_component) catch |err| { std.debug.print("Text input failed: {}\n", .{err}); }; }, c.SDL_EVENT_TEXT_EDITING => { - const focused = &sessions[anim_state.focused_session]; + const focused = sessions[anim_state.focused_session]; input_text.handleTextEditing( focused, &ime_composition, @@ -556,11 +726,11 @@ pub fn run() !void { cell_height_pixels, render_width, render_height, - grid_cols, - grid_rows, + grid.cols, + grid.rows, ) orelse continue; - var session = &sessions[hovered_session]; + var session = sessions[hovered_session]; try session.ensureSpawnedWithLoop(&loop); const escaped = worktree.shellQuotePath(allocator, drop_path) catch |err| { @@ -578,12 +748,12 @@ pub fn run() !void { c.SDL_EVENT_KEY_DOWN => { const key = scaled_event.key.key; const mod = scaled_event.key.mod; - const focused = &sessions[anim_state.focused_session]; + const focused = sessions[anim_state.focused_session]; const has_gui = (mod & c.SDL_KMOD_GUI) != 0; const has_blocking_mod = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT)) != 0; const terminal_shortcut: ?usize = if (worktree_comp_ptr.overlay.state == .Closed) - input.terminalSwitchShortcut(key, mod, grid_cols * grid_rows) + input.terminalSwitchShortcut(key, mod, grid.cols * grid.rows) else null; @@ -598,9 +768,10 @@ pub fn run() !void { if (has_gui and !has_blocking_mod and key == c.SDLK_W) { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘W", now); const session_idx = anim_state.focused_session; - const session = &sessions[session_idx]; + const session = sessions[session_idx]; if (!session.spawned) { + log.info("close requested on unspawned session idx={d} mode={s}", .{ session_idx, @tagName(anim_state.mode) }); continue; } @@ -613,16 +784,177 @@ pub fn run() !void { .{ .DespawnSession = session_idx }, ); } else { + const spawned_count = countSpawnedSessions(sessions); + log.info("close requested idx={d} spawned_count={d} mode={s}", .{ + session_idx, + spawned_count, + @tagName(anim_state.mode), + }); + if (spawned_count == 1) { + var working_dir = WorkingDir.init(allocator, session.cwd_path); + defer working_dir.deinit(allocator); + + log.info("relaunching last session idx={d} grid_resizing={}", .{ + session_idx, + grid.is_resizing, + }); + relaunch_trace_frames = 120; + try session.relaunch(working_dir.cwd_z, &loop); + session_interaction_component.resetView(session_idx); + session_interaction_component.setStatus(session_idx, .running); + session_interaction_component.setAttention(session_idx, false); + session.markDirty(); + grid.cancelResize(); + log.info("relaunch complete idx={d} spawned={} dead={}", .{ + session_idx, + session.spawned, + session.dead, + }); + anim_state.mode = .Full; + continue; + } + + // If in full view, collapse to grid first if (anim_state.mode == .Full) { if (animations_enabled) { - grid_nav.startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid_cols); + grid_nav.startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid.cols); } else { anim_state.mode = .Grid; } } + + var old_positions: ?std.ArrayList(SessionIndexSnapshot) = null; + defer if (old_positions) |*snapshots| { + snapshots.deinit(allocator); + }; + if (animations_enabled and anim_state.mode == .Grid) { + old_positions = collectSessionIndexSnapshots(sessions, allocator) catch |err| blk: { + std.debug.print("Failed to snapshot session positions: {}\n", .{err}); + break :blk null; + }; + } + + // Close the terminal session.deinit(allocator); session_interaction_component.resetView(session_idx); session.markDirty(); + + compactSessions(sessions, session_interaction_component.viewSlice(), &render_cache, &anim_state); + + // Count remaining spawned sessions after closing + const remaining_count = countSpawnedSessions(sessions); + const max_spawned_idx = highestSpawnedIndex(sessions); + const required_slots = if (max_spawned_idx) |max_idx| max_idx + 1 else 0; + + // Don't shrink below 1 terminal + if (remaining_count == 0) { + // Re-spawn a fresh terminal in slot 0 + try sessions[0].ensureSpawnedWithLoop(&loop); + anim_state.focused_session = 0; + grid.cols = 1; + grid.rows = 1; + anim_state.mode = .Full; + } else if (remaining_count == 1) { + // Only 1 terminal remains - go directly to Full mode, no resize animation + grid.cols = 1; + grid.rows = 1; + cell_width_pixels = render_width; + cell_height_pixels = render_height; + if (!sessions[anim_state.focused_session].spawned) { + for (sessions, 0..) |s, idx| { + if (s.spawned) { + anim_state.focused_session = idx; + break; + } + } + } + anim_state.mode = .Full; + } else { + const new_dims = GridLayout.calculateDimensions(required_slots); + const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; + + if (should_shrink) { + const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; + const grid_will_resize = new_dims.cols != grid.cols or new_dims.rows != grid.rows; + if (can_animate_reflow) { + if (old_positions) |snapshots| { + var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { + std.debug.print("Failed to collect session moves: {}\n", .{err}); + break :blk null; + }; + if (move_result) |*moves| { + defer moves.list.deinit(allocator); + if (grid_will_resize or moves.moved) { + grid.startResize(new_dims.cols, new_dims.rows, now, render_width, render_height, moves.list.items) catch |err| { + std.debug.print("Failed to start grid resize animation: {}\n", .{err}); + }; + anim_state.mode = .GridResizing; + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + + cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); + cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); + + // Update focus to a valid session + if (!sessions[anim_state.focused_session].spawned) { + var new_focus: usize = 0; + for (sessions, 0..) |s, idx| { + if (s.spawned) { + new_focus = idx; + break; + } + } + anim_state.focused_session = new_focus; + } + + std.debug.print("Grid shrunk to {d}x{d} with {d} terminals\n", .{ grid.cols, grid.rows, remaining_count }); + } else { + const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; + if (can_animate_reflow) { + if (old_positions) |snapshots| { + var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { + std.debug.print("Failed to collect session moves: {}\n", .{err}); + break :blk null; + }; + if (move_result) |*moves| { + defer moves.list.deinit(allocator); + if (moves.moved) { + grid.startResize(grid.cols, grid.rows, now, render_width, render_height, moves.list.items) catch |err| { + std.debug.print("Failed to start grid reflow animation: {}\n", .{err}); + }; + anim_state.mode = .GridResizing; + } + } + } + } + // Grid doesn't need to shrink, just update focus if needed + if (!sessions[anim_state.focused_session].spawned) { + // Find the next spawned session + var new_focus: usize = 0; + for (sessions, 0..) |s, idx| { + if (s.spawned) { + new_focus = idx; + break; + } + } + anim_state.focused_session = new_focus; + } + } + } } continue; } @@ -653,7 +985,7 @@ pub fn run() !void { font.metrics = metrics_ptr; font_size = target_size; - const term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows); + const term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid.cols, grid.rows); full_cols = term_size.cols; full_rows = term_size.rows; layout.applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); @@ -670,42 +1002,97 @@ pub fn run() !void { ui.showToast(notification_msg, now); } else if (key == c.SDLK_N and has_gui and !has_blocking_mod and (anim_state.mode == .Full or anim_state.mode == .Grid)) { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘N", now); - // In grid mode, the focused slot might be unspawned - use it directly - const target_idx: ?usize = if (!focused.spawned) - anim_state.focused_session - else - findNextFreeSession(sessions, anim_state.focused_session); - - if (target_idx) |next_free_idx| { - const cwd_path = focused.cwd_path; - var cwd_buf: ?[]u8 = null; - const cwd_z: ?[:0]const u8 = if (cwd_path) |path| blk: { - const buf = allocator.alloc(u8, path.len + 1) catch break :blk null; - @memcpy(buf[0..path.len], path); - buf[path.len] = 0; - cwd_buf = buf; - break :blk buf[0..path.len :0]; - } else null; - - defer if (cwd_buf) |buf| allocator.free(buf); - - try sessions[next_free_idx].ensureSpawnedWithDir(cwd_z, &loop); - session_interaction_component.setStatus(next_free_idx, .running); - session_interaction_component.setAttention(next_free_idx, false); + + // Count currently spawned sessions + const spawned_count = countSpawnedSessions(sessions); + + // Check if we need to expand the grid + if (grid.needsExpansion(spawned_count)) { + // Calculate new grid dimensions + const new_dims = GridLayout.calculateDimensions(spawned_count + 1); + if (new_dims.cols * new_dims.rows > grid_layout.MAX_TERMINALS) { + ui.showToast("Maximum terminals reached", now); + continue; + } + + // Get working directory from focused session + var working_dir = WorkingDir.init(allocator, focused.cwd_path); + defer working_dir.deinit(allocator); + + const new_capacity = new_dims.cols * new_dims.rows; + const new_idx = findNextFreeSlotAfter(sessions, new_capacity, anim_state.focused_session) orelse { + ui.showToast("All terminals in use", now); + continue; + }; + + // Collect active sessions for animation + var moves = collectSessionMovesCurrent(sessions, allocator) catch |err| { + std.debug.print("Failed to collect session moves: {}\n", .{err}); + continue; + }; + defer moves.deinit(allocator); + + // Update grid dimensions and start animation + if (animations_enabled) { + grid.startResize(new_dims.cols, new_dims.rows, now, render_width, render_height, moves.items) catch |err| { + std.debug.print("Failed to start grid resize animation: {}\n", .{err}); + }; + anim_state.mode = .GridResizing; + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + + // Spawn new terminal + try sessions[new_idx].ensureSpawnedWithDir(working_dir.cwd_z, &loop); + session_interaction_component.setStatus(new_idx, .running); + session_interaction_component.setAttention(new_idx, false); + + // 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))); session_interaction_component.clearSelection(anim_state.focused_session); - session_interaction_component.clearSelection(next_free_idx); + session_interaction_component.clearSelection(new_idx); anim_state.previous_session = anim_state.focused_session; - anim_state.focused_session = next_free_idx; + anim_state.focused_session = new_idx; - const buf_size = grid_nav.gridNotificationBufferSize(grid_cols, grid_rows); + const buf_size = grid_nav.gridNotificationBufferSize(grid.cols, grid.rows); const notification_buf = try allocator.alloc(u8, buf_size); defer allocator.free(notification_buf); - const notification_msg = try grid_nav.formatGridNotification(notification_buf, next_free_idx, grid_cols, grid_rows); + const notification_msg = try grid_nav.formatGridNotification(notification_buf, new_idx, grid.cols, grid.rows); ui.showToast(notification_msg, now); + std.debug.print("Grid expanded to {d}x{d}, new terminal at index {d}\n", .{ grid.cols, grid.rows, new_idx }); } else { - ui.showToast("All terminals in use", now); + // Grid has space, find next free slot + const target_idx: ?usize = if (!focused.spawned) + anim_state.focused_session + else + findNextFreeSlotAfter(sessions, grid.capacity(), anim_state.focused_session); + + if (target_idx) |next_free_idx| { + var working_dir = WorkingDir.init(allocator, focused.cwd_path); + defer working_dir.deinit(allocator); + + try sessions[next_free_idx].ensureSpawnedWithDir(working_dir.cwd_z, &loop); + session_interaction_component.setStatus(next_free_idx, .running); + session_interaction_component.setAttention(next_free_idx, false); + + session_interaction_component.clearSelection(anim_state.focused_session); + session_interaction_component.clearSelection(next_free_idx); + + anim_state.previous_session = anim_state.focused_session; + anim_state.focused_session = next_free_idx; + + const buf_size = grid_nav.gridNotificationBufferSize(grid.cols, grid.rows); + const notification_buf = try allocator.alloc(u8, buf_size); + defer allocator.free(notification_buf); + const notification_msg = try grid_nav.formatGridNotification(notification_buf, next_free_idx, grid.cols, grid.rows); + ui.showToast(notification_msg, now); + } else { + ui.showToast("All terminals in use", now); + } } } else if (terminal_shortcut) |idx| { const hotkey_label = input.terminalHotkeyLabel(idx) orelse "⌘?"; @@ -716,8 +1103,8 @@ pub fn run() !void { session_interaction_component.setStatus(idx, .running); session_interaction_component.setAttention(idx, false); - const grid_row: c_int = @intCast(idx / grid_cols); - const grid_col: c_int = @intCast(idx % grid_cols); + const grid_row: c_int = @intCast(idx / grid.cols); + const grid_col: c_int = @intCast(idx % grid.cols); const start_rect = Rect{ .x = grid_col * cell_width_pixels, .y = grid_row * cell_height_pixels, @@ -748,10 +1135,10 @@ pub fn run() !void { session_interaction_component.setAttention(idx, false); anim_state.focused_session = idx; - const buf_size = grid_nav.gridNotificationBufferSize(grid_cols, grid_rows); + const buf_size = grid_nav.gridNotificationBufferSize(grid.cols, grid.rows); const notification_buf = try allocator.alloc(u8, buf_size); defer allocator.free(notification_buf); - const notification_msg = try grid_nav.formatGridNotification(notification_buf, idx, grid_cols, grid_rows); + const notification_msg = try grid_nav.formatGridNotification(notification_buf, idx, grid.cols, grid.rows); ui.showToast(notification_msg, now); std.debug.print("Switched to session via hotkey: {d}\n", .{idx}); } @@ -766,7 +1153,7 @@ pub fn run() !void { }; ui.showHotkey(arrow, now); } - try grid_nav.navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, false, grid_cols, grid_rows, &loop); + try grid_nav.navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, false, grid.cols, grid.rows, &loop); const new_session = anim_state.focused_session; sessions[new_session].markDirty(); std.debug.print("Grid nav to session {d} (with wrapping)\n", .{new_session}); @@ -780,12 +1167,12 @@ pub fn run() !void { }; ui.showHotkey(arrow, now); } - try grid_nav.navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, animations_enabled, grid_cols, grid_rows, &loop); + try grid_nav.navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, animations_enabled, grid.cols, grid.rows, &loop); - const buf_size = grid_nav.gridNotificationBufferSize(grid_cols, grid_rows); + const buf_size = grid_nav.gridNotificationBufferSize(grid.cols, grid.rows); const notification_buf = try allocator.alloc(u8, buf_size); defer allocator.free(notification_buf); - const notification_msg = try grid_nav.formatGridNotification(notification_buf, anim_state.focused_session, grid_cols, grid_rows); + const notification_msg = try grid_nav.formatGridNotification(notification_buf, anim_state.focused_session, grid.cols, grid.rows); ui.showToast(notification_msg, now); std.debug.print("Full mode grid nav to session {d}\n", .{anim_state.focused_session}); @@ -797,14 +1184,17 @@ pub fn run() !void { } } else if (key == c.SDLK_RETURN and (mod & c.SDL_KMOD_GUI) != 0 and anim_state.mode == .Grid) { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘↵", now); + if (countSpawnedSessions(sessions) == 1) { + continue; + } const clicked_session = anim_state.focused_session; try sessions[clicked_session].ensureSpawnedWithLoop(&loop); session_interaction_component.setStatus(clicked_session, .running); session_interaction_component.setAttention(clicked_session, false); - const grid_row: c_int = @intCast(clicked_session / grid_cols); - const grid_col: c_int = @intCast(clicked_session % grid_cols); + const grid_row: c_int = @intCast(clicked_session / grid.cols); + const grid_col: c_int = @intCast(clicked_session % grid.cols); const start_rect = Rect{ .x = grid_col * cell_width_pixels, .y = grid_row * cell_height_pixels, @@ -835,7 +1225,7 @@ pub fn run() !void { c.SDL_EVENT_KEY_UP => { const key = scaled_event.key.key; if (key == c.SDLK_ESCAPE and input.canHandleEscapePress(anim_state.mode)) { - const focused = &sessions[anim_state.focused_session]; + const focused = sessions[anim_state.focused_session]; if (focused.spawned and !focused.dead and focused.shell != null) { const esc_byte: [1]u8 = .{27}; _ = focused.shell.?.write(&esc_byte) catch |err| { @@ -849,13 +1239,31 @@ pub fn run() !void { } } - try loop.run(.no_wait); + loop.run(.no_wait) catch |err| { + log.err("xev loop run failed: {}", .{err}); + return err; + }; + if (relaunch_trace_frames > 0) { + log.info("frame trace after xev run", .{}); + } - for (sessions) |*session| { + for (sessions) |session| { + if (relaunch_trace_frames > 0 and session.spawned) { + log.info("frame trace before process session idx={d} id={d}", .{ session.slot_index, session.id }); + } session.checkAlive(); - try session.processOutput(); - try session.flushPendingWrites(); + session.processOutput() catch |err| { + log.err("session {d}: process output failed: {}", .{ session.id, err }); + return err; + }; + session.flushPendingWrites() catch |err| { + log.err("session {d}: flush pending writes failed: {}", .{ session.id, err }); + return err; + }; session.updateCwd(now); + if (relaunch_trace_frames > 0 and session.spawned) { + log.info("frame trace after process session idx={d} id={d}", .{ session.slot_index, session.id }); + } } const any_session_dirty = render_cache.anyDirty(sessions); @@ -863,16 +1271,15 @@ pub fn run() !void { defer notifications.deinit(allocator); const had_notifications = notifications.items.len > 0; for (notifications.items) |note| { - if (note.session < sessions.len) { - session_interaction_component.setStatus(note.session, note.state); - const wants_attention = switch (note.state) { - .awaiting_approval, .done => true, - else => false, - }; - const is_focused_full = anim_state.mode == .Full and anim_state.focused_session == note.session; - session_interaction_component.setAttention(note.session, if (is_focused_full) false else wants_attention); - std.debug.print("Session {d} status -> {s}\n", .{ note.session, @tagName(note.state) }); - } + const session_idx = findSessionIndexById(sessions, note.session) orelse continue; + session_interaction_component.setStatus(session_idx, note.state); + const wants_attention = switch (note.state) { + .awaiting_approval, .done => true, + else => false, + }; + const is_focused_full = anim_state.mode == .Full and anim_state.focused_session == session_idx; + session_interaction_component.setAttention(session_idx, if (is_focused_full) false else wants_attention); + std.debug.print("Session {d} (slot {d}) status -> {s}\n", .{ note.session, session_idx, @tagName(note.state) }); } var focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); @@ -883,8 +1290,8 @@ pub fn run() !void { ui_scale, cell_width_pixels, cell_height_pixels, - grid_cols, - grid_rows, + grid.cols, + grid.rows, full_cols, full_rows, &anim_state, @@ -912,8 +1319,8 @@ pub fn run() !void { session_interaction_component.setStatus(idx, .running); session_interaction_component.setAttention(idx, false); - const grid_row: c_int = @intCast(idx / grid_cols); - const grid_col: c_int = @intCast(idx % grid_cols); + const grid_row: c_int = @intCast(idx / grid.cols); + const grid_col: c_int = @intCast(idx % grid.cols); const cell_rect = Rect{ .x = grid_col * cell_width_pixels, .y = grid_row * cell_height_pixels, @@ -941,24 +1348,154 @@ pub fn run() !void { if (idx < sessions.len) { if (anim_state.mode == .Full and anim_state.focused_session == idx) { if (animations_enabled) { - grid_nav.startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid_cols); + grid_nav.startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid.cols); } else { anim_state.mode = .Grid; } } + log.info("ui despawn requested idx={d} mode={s} spawned_count={d}", .{ + idx, + @tagName(anim_state.mode), + countSpawnedSessions(sessions), + }); + var old_positions: ?std.ArrayList(SessionIndexSnapshot) = null; + defer if (old_positions) |*snapshots| { + snapshots.deinit(allocator); + }; + if (animations_enabled and anim_state.mode == .Grid) { + old_positions = collectSessionIndexSnapshots(sessions, allocator) catch |err| blk: { + std.debug.print("Failed to snapshot session positions: {}\n", .{err}); + break :blk null; + }; + } sessions[idx].deinit(allocator); session_interaction_component.resetView(idx); sessions[idx].markDirty(); + compactSessions(sessions, session_interaction_component.viewSlice(), &render_cache, &anim_state); std.debug.print("UI requested despawn: {d}\n", .{idx}); + + // Handle grid contraction + const remaining_count = countSpawnedSessions(sessions); + const max_spawned_idx = highestSpawnedIndex(sessions); + const required_slots = if (max_spawned_idx) |max_idx| max_idx + 1 else 0; + + if (remaining_count == 0) { + // Re-spawn a fresh terminal in slot 0 + sessions[0].ensureSpawnedWithLoop(&loop) catch |err| { + std.debug.print("Failed to respawn terminal: {}\n", .{err}); + }; + anim_state.focused_session = 0; + grid.cols = 1; + grid.rows = 1; + anim_state.mode = .Full; + } else if (remaining_count == 1) { + // Only 1 terminal remains - go directly to Full mode, no resize animation + grid.cols = 1; + grid.rows = 1; + cell_width_pixels = render_width; + cell_height_pixels = render_height; + if (!sessions[anim_state.focused_session].spawned) { + for (sessions, 0..) |s, i| { + if (s.spawned) { + anim_state.focused_session = i; + break; + } + } + } + anim_state.mode = .Full; + } else { + const new_dims = GridLayout.calculateDimensions(required_slots); + const should_shrink = new_dims.cols < grid.cols or new_dims.rows < grid.rows; + if (should_shrink) { + const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; + const grid_will_resize = new_dims.cols != grid.cols or new_dims.rows != grid.rows; + if (can_animate_reflow) { + if (old_positions) |snapshots| { + var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { + std.debug.print("Failed to collect session moves: {}\n", .{err}); + break :blk null; + }; + if (move_result) |*moves| { + defer moves.list.deinit(allocator); + if (grid_will_resize or moves.moved) { + grid.startResize(new_dims.cols, new_dims.rows, now, render_width, render_height, moves.list.items) catch |err| { + std.debug.print("Failed to start grid resize animation: {}\n", .{err}); + }; + anim_state.mode = .GridResizing; + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + } else { + grid.cols = new_dims.cols; + grid.rows = new_dims.rows; + } + + cell_width_pixels = @divFloor(render_width, @as(c_int, @intCast(grid.cols))); + cell_height_pixels = @divFloor(render_height, @as(c_int, @intCast(grid.rows))); + + if (!sessions[anim_state.focused_session].spawned) { + var new_focus: usize = 0; + for (sessions, 0..) |s, i| { + if (s.spawned) { + new_focus = i; + break; + } + } + anim_state.focused_session = new_focus; + } + std.debug.print("Grid shrunk to {d}x{d} with {d} terminals\n", .{ grid.cols, grid.rows, remaining_count }); + } else { + const can_animate_reflow = animations_enabled and anim_state.mode == .Grid; + if (can_animate_reflow) { + if (old_positions) |snapshots| { + var move_result: ?SessionMoves = collectSessionMovesFromSnapshots(sessions, snapshots.items, allocator) catch |err| blk: { + std.debug.print("Failed to collect session moves: {}\n", .{err}); + break :blk null; + }; + if (move_result) |*moves| { + defer moves.list.deinit(allocator); + if (moves.moved) { + grid.startResize(grid.cols, grid.rows, now, render_width, render_height, moves.list.items) catch |err| { + std.debug.print("Failed to start grid reflow animation: {}\n", .{err}); + }; + anim_state.mode = .GridResizing; + } + } + } + } + if (!sessions[anim_state.focused_session].spawned) { + var new_focus: usize = 0; + for (sessions, 0..) |s, i| { + if (s.spawned) { + new_focus = i; + break; + } + } + anim_state.focused_session = new_focus; + } + } + } } }, .RequestCollapseFocused => { if (anim_state.mode == .Full) { - if (animations_enabled) { - grid_nav.startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid_cols); + const spawned_count = countSpawnedSessions(sessions); + if (spawned_count == 1) { + std.debug.print("UI requested collapse ignored (single terminal)\n", .{}); + } else if (animations_enabled) { + grid_nav.startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid.cols); } else { - const grid_row: c_int = @intCast(anim_state.focused_session / grid_cols); - const grid_col: c_int = @intCast(anim_state.focused_session % grid_cols); + const grid_row: c_int = @intCast(anim_state.focused_session / grid.cols); + const grid_col: c_int = @intCast(anim_state.focused_session % grid.cols); anim_state.mode = .Grid; anim_state.start_time = now; anim_state.start_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; @@ -999,7 +1536,7 @@ pub fn run() !void { defer allocator.free(switch_action.path); if (switch_action.session >= sessions.len) continue; - var session = &sessions[switch_action.session]; + var session = sessions[switch_action.session]; if (session.hasForegroundProcess()) { ui.showToast("Stop the running process first", now); continue; @@ -1024,7 +1561,7 @@ pub fn run() !void { defer allocator.free(create_action.base_path); defer allocator.free(create_action.name); if (create_action.session >= sessions.len) continue; - var session = &sessions[create_action.session]; + var session = sessions[create_action.session]; if (session.hasForegroundProcess()) { ui.showToast("Stop the running process first", now); @@ -1064,7 +1601,7 @@ pub fn run() !void { .RemoveWorktree => |remove_action| { defer allocator.free(remove_action.path); if (remove_action.session >= sessions.len) continue; - var session = &sessions[remove_action.session]; + var session = sessions[remove_action.session]; if (session.hasForegroundProcess()) { ui.showToast("Stop the running process first", now); @@ -1075,7 +1612,7 @@ pub fn run() !void { continue; } - for (sessions, 0..) |*other_session, idx| { + for (sessions, 0..) |other_session, idx| { if (idx == remove_action.session) continue; if (!other_session.spawned or other_session.dead) continue; @@ -1139,9 +1676,21 @@ pub fn run() !void { } } + // Handle grid resize animation completion + if (anim_state.mode == .GridResizing) { + if (grid.updateResize(now)) { + anim_state.mode = .Grid; + // Mark all sessions dirty to refresh render cache + for (sessions) |session| { + session.markDirty(); + } + std.debug.print("Grid resize complete: {d}x{d}\n", .{ grid.cols, grid.rows }); + } + } + const desired_font_scale = layout.gridFontScaleForMode(anim_state.mode, config.grid.font_scale); if (desired_font_scale != current_grid_font_scale) { - const term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows); + const term_size = layout.calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid.cols, grid.rows); full_cols = term_size.cols; full_rows = term_size.rows; layout.applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); @@ -1162,8 +1711,8 @@ pub fn run() !void { ui_scale, cell_width_pixels, cell_height_pixels, - grid_cols, - grid_rows, + grid.cols, + grid.rows, full_cols, full_rows, &anim_state, @@ -1179,13 +1728,45 @@ pub fn run() !void { const should_render = animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications or last_render_stale; if (should_render) { - try renderer_mod.render(renderer, &render_cache, sessions, session_interaction_component.viewSlice(), cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, &theme, config.grid.font_scale); + if (relaunch_trace_frames > 0) { + log.info("frame trace before render", .{}); + } + renderer_mod.render( + renderer, + &render_cache, + sessions, + session_interaction_component.viewSlice(), + cell_width_pixels, + cell_height_pixels, + grid.cols, + grid.rows, + &anim_state, + now, + &font, + full_cols, + full_rows, + render_width, + render_height, + &theme, + config.grid.font_scale, + &grid, + ) catch |err| { + log.err("render failed: {}", .{err}); + return err; + }; ui.render(&ui_render_host, renderer); _ = c.SDL_RenderPresent(renderer); + if (relaunch_trace_frames > 0) { + log.info("frame trace after render", .{}); + } metrics_mod.increment(.frame_count); last_render_ns = std.time.nanoTimestamp(); } + if (relaunch_trace_frames > 0) { + relaunch_trace_frames -= 1; + } + const is_idle = !animating and !any_session_dirty and !ui_needs_frame and !processed_event and !had_notifications; // When vsync is enabled and we're active, let vsync handle frame pacing. // When idle, always throttle to save power regardless of vsync. @@ -1202,12 +1783,12 @@ pub fn run() !void { } if (builtin.os.tag == .macos) { - persistence.clearTerminals(); + persistence.clearTerminalPaths(allocator); for (sessions, 0..) |session, idx| { if (!session.spawned or session.dead) continue; if (session.cwd_path) |path| { if (path.len == 0) continue; - persistence.setTerminal(allocator, idx, grid_cols, path) catch |err| { + persistence.appendTerminalPath(allocator, path) catch |err| { std.debug.print("Failed to persist terminal {d}: {}\n", .{ idx, err }); }; } diff --git a/src/app/terminal_actions.zig b/src/app/terminal_actions.zig index ad0a58c..421b4a6 100644 --- a/src/app/terminal_actions.zig +++ b/src/app/terminal_actions.zig @@ -15,7 +15,7 @@ pub fn pasteText( ) !void { if (text.len == 0) return; - session_interaction.resetScrollIfNeeded(session.id); + session_interaction.resetScrollIfNeeded(session.slot_index); const terminal = session.terminal orelse return error.NoTerminal; if (session.shell == null) return error.NoShell; diff --git a/src/app/ui_host.zig b/src/app/ui_host.zig index 0922134..b6bd329 100644 --- a/src/app/ui_host.zig +++ b/src/app/ui_host.zig @@ -58,7 +58,7 @@ pub fn makeUiHost( term_cols: u16, term_rows: u16, anim_state: *const AnimationState, - sessions: []const SessionState, + sessions: []const *SessionState, buffer: []ui_mod.SessionUiInfo, focused_has_foreground_process: bool, theme: *const colors_mod.Theme, @@ -72,7 +72,7 @@ pub fn makeUiHost( }; } - const focused_session = &sessions[anim_state.focused_session]; + const focused_session = sessions[anim_state.focused_session]; const focused_cwd = focused_session.cwd_path; const animating_rect: ?Rect = switch (anim_state.mode) { .Expanding, .Collapsing => anim_state.getCurrentRect(now), diff --git a/src/config.zig b/src/config.zig index c8de927..3261c00 100644 --- a/src/config.zig +++ b/src/config.zig @@ -2,10 +2,6 @@ const std = @import("std"); const fs = std.fs; const toml = @import("toml"); -pub const MIN_GRID_SIZE: i32 = 1; -pub const MAX_GRID_SIZE: i32 = 12; -pub const DEFAULT_GRID_ROWS: i32 = 3; -pub const DEFAULT_GRID_COLS: i32 = 3; pub const MIN_GRID_FONT_SCALE: f32 = 0.5; pub const MAX_GRID_FONT_SCALE: f32 = 3.0; @@ -69,8 +65,6 @@ pub const WindowConfig = struct { }; pub const GridConfig = struct { - rows: i32 = DEFAULT_GRID_ROWS, - cols: i32 = DEFAULT_GRID_COLS, font_scale: f32 = 1.0, }; @@ -239,32 +233,35 @@ pub const MetricsConfig = struct { pub const Persistence = struct { const TerminalKeyPrefix = "terminal_"; - pub const TerminalEntry = struct { - index: usize, - path: []const u8, - }; - window: WindowConfig = .{}, font_size: c_int = 14, - terminals: std.StringHashMap([]const u8), + terminal_paths: std.ArrayListUnmanaged([]const u8) = .{}, - const TomlPersistence = struct { + const TomlPersistenceV2 = struct { + window: WindowConfig = .{}, + font_size: c_int = 14, + terminals: ?[]const []const u8 = null, + }; + + const TomlPersistenceV1 = struct { window: WindowConfig = .{}, font_size: c_int = 14, terminals: ?toml.HashMap([]const u8) = null, }; + const TomlPersistenceSerialized = struct { + window: WindowConfig = .{}, + font_size: c_int = 14, + }; + pub fn init(allocator: std.mem.Allocator) Persistence { - return .{ - .window = .{}, - .font_size = 14, - .terminals = std.StringHashMap([]const u8).init(allocator), - }; + _ = allocator; + return .{}; } - pub fn deinit(self: *Persistence) void { - self.clearTerminals(); - self.terminals.deinit(); + pub fn deinit(self: *Persistence, allocator: std.mem.Allocator) void { + self.clearTerminalPaths(allocator); + self.terminal_paths.deinit(allocator); } pub fn load(allocator: std.mem.Allocator) !Persistence { @@ -282,30 +279,39 @@ pub const Persistence = struct { const contents = try file.readToEndAlloc(allocator, 1024 * 1024); defer allocator.free(contents); - var parser = toml.Parser(TomlPersistence).init(allocator); - defer parser.deinit(); + var persistence = Persistence.init(allocator); + + var parser_v2 = toml.Parser(TomlPersistenceV2).init(allocator); + defer parser_v2.deinit(); - var result = parser.parseString(contents) catch |err| { + if (parser_v2.parseString(contents)) |result| { + defer result.deinit(); + persistence.window = result.value.window; + persistence.font_size = result.value.font_size; + + if (result.value.terminals) |paths| { + for (paths) |path| { + try persistence.appendTerminalPath(allocator, path); + } + } + + return persistence; + } else |_| {} + + var parser_v1 = toml.Parser(TomlPersistenceV1).init(allocator); + defer parser_v1.deinit(); + + var result_v1 = parser_v1.parseString(contents) catch |err| { std.log.err("Failed to parse persistence TOML: {any}", .{err}); return Persistence.init(allocator); }; - defer result.deinit(); - - var persistence = Persistence.init(allocator); - persistence.window = result.value.window; - persistence.font_size = result.value.font_size; - - if (result.value.terminals) |stored| { - var it = stored.map.iterator(); - while (it.next()) |entry| { - const key_copy = try allocator.dupe(u8, entry.key_ptr.*); - errdefer allocator.free(key_copy); + defer result_v1.deinit(); - const val_copy = try allocator.dupe(u8, entry.value_ptr.*); - errdefer allocator.free(val_copy); + persistence.window = result_v1.value.window; + persistence.font_size = result_v1.value.font_size; - try persistence.terminals.put(key_copy, val_copy); - } + if (result_v1.value.terminals) |stored| { + try persistence.appendLegacyTerminals(allocator, stored); } return persistence; @@ -323,7 +329,19 @@ pub const Persistence = struct { var writer = std.Io.Writer.Allocating.init(allocator); defer writer.deinit(); - try toml.serialize(allocator, self, &writer.writer); + try toml.serialize(allocator, TomlPersistenceSerialized{ + .window = self.window, + .font_size = self.font_size, + }, &writer.writer); + + if (self.terminal_paths.items.len > 0) { + try writer.writer.writeAll("terminals = ["); + for (self.terminal_paths.items, 0..) |path, idx| { + if (idx != 0) try writer.writer.writeAll(", "); + try writeTomlString(&writer.writer, path); + } + try writer.writer.writeAll("]\n"); + } const serialized = writer.written(); const file = try fs.createFileAbsolute(persistence_path, .{ .truncate = true }); @@ -336,85 +354,73 @@ pub const Persistence = struct { return try fs.path.join(allocator, &[_][]const u8{ home, ".config", "architect", "persistence.toml" }); } - pub fn pruneTerminals(self: *Persistence, allocator: std.mem.Allocator, grid_cols: usize, grid_rows: usize) !bool { - var to_remove = std.ArrayList([]const u8).empty; - defer to_remove.deinit(allocator); - - var seen = std.AutoHashMap(usize, void).init(allocator); - defer seen.deinit(); - - var changed = false; - var it = self.terminals.iterator(); - while (it.next()) |entry| { - const parsed = parseTerminalKey(entry.key_ptr.*) orelse { - try to_remove.append(allocator, entry.key_ptr.*); - continue; - }; - - if (parsed.row >= grid_rows or parsed.col >= grid_cols) { - try to_remove.append(allocator, entry.key_ptr.*); - continue; - } - - const index = parsed.row * grid_cols + parsed.col; - if (seen.contains(index)) { - try to_remove.append(allocator, entry.key_ptr.*); - continue; - } + pub fn appendTerminalPath(self: *Persistence, allocator: std.mem.Allocator, path: []const u8) !void { + const value = try allocator.dupe(u8, path); + errdefer allocator.free(value); + try self.terminal_paths.append(allocator, value); + } - try seen.put(index, {}); + pub fn clearTerminalPaths(self: *Persistence, allocator: std.mem.Allocator) void { + for (self.terminal_paths.items) |path| { + allocator.free(path); } + self.terminal_paths.clearRetainingCapacity(); + } - for (to_remove.items) |key| { - if (self.terminals.fetchRemove(key)) |removed| { - allocator.free(removed.key); - allocator.free(removed.value); - changed = true; - } - } + fn appendLegacyTerminals(self: *Persistence, allocator: std.mem.Allocator, stored: toml.HashMap([]const u8)) !void { + const LegacyTerminalEntry = struct { + row: usize, + col: usize, + path: []const u8, - return changed; - } + fn lessThan(_: void, lhs: @This(), rhs: @This()) bool { + if (lhs.row != rhs.row) return lhs.row < rhs.row; + return lhs.col < rhs.col; + } + }; - pub fn collectTerminalEntries(self: *const Persistence, allocator: std.mem.Allocator, grid_cols: usize, grid_rows: usize) !std.ArrayList(TerminalEntry) { - var entries = std.ArrayList(TerminalEntry).empty; - errdefer entries.deinit(allocator); + var entries = std.ArrayList(LegacyTerminalEntry).empty; + defer entries.deinit(allocator); - var it = self.terminals.iterator(); + var it = stored.map.iterator(); while (it.next()) |entry| { const parsed = parseTerminalKey(entry.key_ptr.*) orelse continue; - if (parsed.row >= grid_rows or parsed.col >= grid_cols) continue; - const index = parsed.row * grid_cols + parsed.col; - try entries.append(allocator, .{ .index = index, .path = entry.value_ptr.* }); + try entries.append(allocator, .{ + .row = parsed.row, + .col = parsed.col, + .path = entry.value_ptr.*, + }); } - return entries; - } - - pub fn setTerminal(self: *Persistence, allocator: std.mem.Allocator, index: usize, grid_cols: usize, path: []const u8) !void { - const row = index / grid_cols; - const col = index % grid_cols; - const key = try std.fmt.allocPrint(allocator, "{s}{d}_{d}", .{ TerminalKeyPrefix, row + 1, col + 1 }); - errdefer allocator.free(key); + std.mem.sort(LegacyTerminalEntry, entries.items, {}, LegacyTerminalEntry.lessThan); - const value = try allocator.dupe(u8, path); - errdefer allocator.free(value); - - if (self.terminals.fetchRemove(key)) |old_entry| { - allocator.free(old_entry.key); - allocator.free(old_entry.value); + for (entries.items) |entry| { + try self.appendTerminalPath(allocator, entry.path); } - - try self.terminals.put(key, value); } - pub fn clearTerminals(self: *Persistence) void { - var it = self.terminals.iterator(); - while (it.next()) |entry| { - self.terminals.allocator.free(entry.key_ptr.*); - self.terminals.allocator.free(entry.value_ptr.*); + fn writeTomlString(writer: *std.Io.Writer, value: []const u8) !void { + _ = try writer.writeByte('"'); + var curr_pos: usize = 0; + while (curr_pos < value.len) { + const next_pos = std.mem.indexOfAnyPos(u8, value, curr_pos, &.{ '"', '\n', '\t', '\r', '\\', 0x0C, 0x08 }) orelse value.len; + try writer.print("{s}", .{value[curr_pos..next_pos]}); + if (next_pos != value.len) { + _ = try writer.writeByte('\\'); + switch (value[next_pos]) { + '"' => _ = try writer.writeByte('"'), + '\n' => _ = try writer.writeByte('n'), + '\t' => _ = try writer.writeByte('t'), + '\r' => _ = try writer.writeByte('r'), + '\\' => _ = try writer.writeByte('\\'), + 0x0C => _ = try writer.writeByte('f'), + 0x08 => _ = try writer.writeByte('b'), + else => unreachable, + } + } + curr_pos = next_pos + 1; } - self.terminals.clearRetainingCapacity(); + _ = try writer.writeByte('"'); } }; @@ -477,10 +483,8 @@ pub const Config = struct { \\# [font] \\# family = "SFNSMono" \\ - \\# Terminal grid size, 1-12 (default: 3x3) + \\# Grid options (grid size is dynamic based on terminal count) \\# [grid] - \\# rows = 3 - \\# cols = 3 \\# font_scale = 1.0 \\ \\# Rendering options @@ -550,8 +554,6 @@ pub const Config = struct { var config = result.value; - config.grid.rows = std.math.clamp(config.grid.rows, MIN_GRID_SIZE, MAX_GRID_SIZE); - config.grid.cols = std.math.clamp(config.grid.cols, MIN_GRID_SIZE, MAX_GRID_SIZE); config.grid.font_scale = std.math.clamp(config.grid.font_scale, MIN_GRID_FONT_SCALE, MAX_GRID_FONT_SCALE); config.font = try config.font.duplicate(allocator); @@ -666,8 +668,6 @@ test "Config - decode sectioned toml" { \\foreground = "#CDD6F4" \\ \\[grid] - \\rows = 3 - \\cols = 4 \\font_scale = 1.25 \\ \\[rendering] @@ -696,8 +696,6 @@ test "Config - decode sectioned toml" { try std.testing.expectEqual(@as(i32, 100), config.window.y); try std.testing.expect(config.theme.background != null); try std.testing.expectEqualStrings("#1E1E2E", config.theme.background.?); - try std.testing.expectEqual(@as(i32, 3), config.grid.rows); - try std.testing.expectEqual(@as(i32, 4), config.grid.cols); try std.testing.expectApproxEqAbs(@as(f32, 1.25), config.grid.font_scale, 0.0001); try std.testing.expectEqual(false, config.rendering.vsync); try std.testing.expectEqual(false, config.ui.show_hotkey_feedback); @@ -712,43 +710,49 @@ test "parseTerminalKey decodes 1-based coordinates" { try std.testing.expect(parseTerminalKey("something_else") == null); } -test "Persistence.pruneTerminals removes out-of-bounds entries" { +test "Persistence.appendTerminalPath preserves order" { const allocator = std.testing.allocator; var persistence = Persistence.init(allocator); - defer persistence.deinit(); + defer persistence.deinit(allocator); - try persistence.setTerminal(allocator, 0, 2, "/one"); + try persistence.appendTerminalPath(allocator, "/one"); + try persistence.appendTerminalPath(allocator, "/two"); - const bad_key = try std.fmt.allocPrint(allocator, "{s}3_1", .{Persistence.TerminalKeyPrefix}); - const bad_value = try allocator.dupe(u8, "/bad"); - try persistence.terminals.put(bad_key, bad_value); - - const changed = try persistence.pruneTerminals(allocator, 2, 2); - try std.testing.expect(changed); - try std.testing.expectEqual(@as(usize, 1), persistence.terminals.count()); + try std.testing.expectEqual(@as(usize, 2), persistence.terminal_paths.items.len); + try std.testing.expectEqualStrings("/one", persistence.terminal_paths.items[0]); + try std.testing.expectEqualStrings("/two", persistence.terminal_paths.items[1]); } -test "Persistence.collectTerminalEntries maps keys to session indices" { +test "Persistence.appendLegacyTerminals migrates row-major order" { const allocator = std.testing.allocator; var persistence = Persistence.init(allocator); - defer persistence.deinit(); + defer persistence.deinit(allocator); - try persistence.setTerminal(allocator, 1, 3, "/a"); - try persistence.setTerminal(allocator, 5, 3, "/b"); + var legacy = toml.HashMap([]const u8){ .map = std.StringHashMap([]const u8).init(allocator) }; + defer { + var it = legacy.map.iterator(); + while (it.next()) |entry| { + allocator.free(entry.key_ptr.*); + allocator.free(entry.value_ptr.*); + } + legacy.map.deinit(); + } - var entries = try persistence.collectTerminalEntries(allocator, 3, 3); - defer entries.deinit(allocator); + const key_b = try allocator.dupe(u8, "terminal_2_1"); + errdefer allocator.free(key_b); + const val_b = try allocator.dupe(u8, "/b"); + errdefer allocator.free(val_b); + try legacy.map.put(key_b, val_b); - try std.testing.expectEqual(@as(usize, 2), entries.items.len); + const key_a = try allocator.dupe(u8, "terminal_1_2"); + errdefer allocator.free(key_a); + const val_a = try allocator.dupe(u8, "/a"); + errdefer allocator.free(val_a); + try legacy.map.put(key_a, val_a); - var seen = std.AutoHashMap(usize, []const u8).init(allocator); - defer seen.deinit(); - for (entries.items) |entry| { - try seen.put(entry.index, entry.path); - } + try persistence.appendLegacyTerminals(allocator, legacy); - try std.testing.expect(seen.contains(1)); - try std.testing.expect(seen.contains(5)); - try std.testing.expectEqualStrings("/a", seen.get(1).?); - try std.testing.expectEqualStrings("/b", seen.get(5).?); + try std.testing.expectEqual(@as(usize, 2), persistence.terminal_paths.items.len); + try std.testing.expectEqualStrings("/a", persistence.terminal_paths.items[0]); + try std.testing.expectEqualStrings("/b", persistence.terminal_paths.items[1]); } diff --git a/src/input/mapper.zig b/src/input/mapper.zig index 102b4af..ca0d8b0 100644 --- a/src/input/mapper.zig +++ b/src/input/mapper.zig @@ -28,7 +28,7 @@ pub fn gridNavShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?GridNavDirection } pub fn canHandleEscapePress(mode: app_state.ViewMode) bool { - return mode != .Grid and mode != .Collapsing; + return mode != .Grid and mode != .Collapsing and mode != .GridResizing; } /// Returns terminal index (0-9) for Cmd+1..9,0 shortcuts. diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 4be0d66..08accce 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -3,6 +3,7 @@ const c = @import("../c.zig"); const colors = @import("../colors.zig"); const ghostty_vt = @import("ghostty-vt"); const app_state = @import("../app/app_state.zig"); +const grid_layout = @import("../app/grid_layout.zig"); const geom = @import("../geom.zig"); const easing = @import("../anim/easing.zig"); const font_mod = @import("../font.zig"); @@ -17,6 +18,7 @@ const SessionState = session_state.SessionState; const SessionViewState = view_state.SessionViewState; const Rect = geom.Rect; const AnimationState = app_state.AnimationState; +const GridLayout = grid_layout.GridLayout; const ATTENTION_THICKNESS: c_int = 3; pub const TERMINAL_PADDING: c_int = 8; @@ -60,7 +62,7 @@ pub const RenderCache = struct { return &self.entries[idx]; } - pub fn anyDirty(self: *RenderCache, sessions: []const SessionState) bool { + pub fn anyDirty(self: *RenderCache, sessions: []const *SessionState) bool { std.debug.assert(sessions.len == self.entries.len); for (sessions, 0..) |session, i| { if (session.render_epoch != self.entries[i].presented_epoch) return true; @@ -72,7 +74,7 @@ pub const RenderCache = struct { pub fn render( renderer: *c.SDL_Renderer, render_cache: *RenderCache, - sessions: []SessionState, + sessions: []const *SessionState, views: []const SessionViewState, cell_width_pixels: c_int, cell_height_pixels: c_int, @@ -87,6 +89,7 @@ pub fn render( window_height: c_int, theme: *const colors.Theme, grid_font_scale: f32, + grid: ?*const GridLayout, ) RenderError!void { _ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255); _ = c.SDL_RenderClear(renderer); @@ -100,7 +103,7 @@ pub fn render( switch (anim_state.mode) { .Grid => { - for (sessions, 0..) |*session, i| { + for (sessions, 0..) |session, i| { const grid_row: c_int = @intCast(i / grid_cols); const grid_col: c_int = @intCast(i % grid_cols); @@ -112,13 +115,13 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, font, term_cols, term_rows, current_time, theme); + try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, true, font, term_cols, term_rows, current_time, theme); } }, .Full => { const full_rect = Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height }; const entry = render_cache.entry(anim_state.focused_session); - try renderSession(renderer, &sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); + try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme); }, .PanningLeft, .PanningRight => { const elapsed = current_time - anim_state.start_time; @@ -130,7 +133,7 @@ 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); + 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); const new_offset = if (anim_state.mode == .PanningLeft) window_width - offset @@ -138,7 +141,7 @@ 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); + 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); }, .PanningUp, .PanningDown => { const elapsed = current_time - anim_state.start_time; @@ -150,7 +153,7 @@ 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); + 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); const new_offset = if (anim_state.mode == .PanningUp) window_height - offset @@ -158,7 +161,7 @@ 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); + 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); }, .Expanding, .Collapsing => { const animating_rect = anim_state.getCurrentRect(current_time); @@ -170,7 +173,7 @@ pub fn render( else 1.0 - (1.0 - grid_scale) * eased; - for (sessions, 0..) |*session, i| { + for (sessions, 0..) |session, i| { if (i != anim_state.focused_session) { const grid_row: c_int = @intCast(i / grid_cols); const grid_col: c_int = @intCast(i % grid_cols); @@ -183,13 +186,76 @@ pub fn render( }; const entry = render_cache.entry(i); - try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, font, term_cols, term_rows, current_time, theme); + try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, true, font, term_cols, term_rows, current_time, theme); } } 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, false, theme); + 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, false, theme); + }, + .GridResizing => { + // Render session contents first so borders draw on top. + for (sessions, 0..) |session, i| { + if (!session.spawned) continue; + + // Get animated rect from GridLayout if available + const cell_rect: Rect = if (grid) |g| blk: { + if (g.getAnimatedRect(i, current_time)) |animated_rect| { + break :blk animated_rect; + } + // New session or no animation - use final position + const pos = g.indexToPosition(i); + break :blk Rect{ + .x = @as(c_int, @intCast(pos.col)) * cell_width_pixels, + .y = @as(c_int, @intCast(pos.row)) * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + } else blk: { + // Fallback: calculate position from index + const grid_row: c_int = @intCast(i / grid_cols); + const grid_col: c_int = @intCast(i % grid_cols); + break :blk Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + }; + + const entry = render_cache.entry(i); + try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, false, font, term_cols, term_rows, current_time, theme); + } + + // Render borders and overlays on top of the animated content. + for (sessions, 0..) |session, i| { + if (!session.spawned) continue; + + const cell_rect: Rect = if (grid) |g| blk: { + if (g.getAnimatedRect(i, current_time)) |animated_rect| { + break :blk animated_rect; + } + const pos = g.indexToPosition(i); + break :blk Rect{ + .x = @as(c_int, @intCast(pos.col)) * cell_width_pixels, + .y = @as(c_int, @intCast(pos.row)) * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + } else blk: { + const grid_row: c_int = @intCast(i / grid_cols); + const grid_col: c_int = @intCast(i % grid_cols); + break :blk Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + }; + + renderSessionOverlays(renderer, &views[i], cell_rect, i == anim_state.focused_session, true, current_time, true, theme); + } }, } } @@ -637,6 +703,7 @@ fn renderGridSessionCached( scale: f32, is_focused: bool, apply_effects: bool, + render_overlays: bool, font: *font_mod.Font, term_cols: u16, term_rows: u16, @@ -666,13 +733,21 @@ fn renderGridSessionCached( .h = @floatFromInt(rect.h), }; _ = c.SDL_RenderTexture(renderer, tex, null, &dest_rect); - renderSessionOverlays(renderer, view, rect, is_focused, apply_effects, current_time_ms, true, theme); + if (render_overlays) { + renderSessionOverlays(renderer, view, rect, is_focused, apply_effects, current_time_ms, true, theme); + } cache_entry.presented_epoch = session.render_epoch; return; } } - try renderSession(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme); + if (render_overlays) { + try renderSession(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme); + return; + } + + try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, theme); + cache_entry.presented_epoch = session.render_epoch; } fn applyTvOverlay(renderer: *c.SDL_Renderer, rect: Rect, is_focused: bool, theme: *const colors.Theme) void { diff --git a/src/session/state.zig b/src/session/state.zig index 05974d7..33470e9 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -23,8 +23,11 @@ extern "c" fn tcgetpgrp(fd: posix.fd_t) posix.pid_t; extern "c" fn ptsname(fd: posix.fd_t) ?[*:0]const u8; const PENDING_WRITE_SHRINK_THRESHOLD: usize = 64 * 1024; +const SESSION_ID_BUF_LEN: usize = 32; +var next_session_id = std.atomic.Value(usize).init(0); pub const SessionState = struct { + slot_index: usize, id: usize, shell: ?shell_mod.Shell, terminal: ?ghostty_vt.Terminal, @@ -35,7 +38,7 @@ pub const SessionState = struct { dead: bool = false, shell_path: []const u8, pty_size: pty_mod.winsize, - session_id_z: [16:0]u8, + session_id_z: [SESSION_ID_BUF_LEN:0]u8, notify_sock_z: [:0]const u8, allocator: std.mem.Allocator, cwd_path: ?[]const u8 = null, @@ -46,9 +49,8 @@ pub const SessionState = struct { pending_write: std.ArrayListUnmanaged(u8) = .empty, /// Process watcher for event-driven exit detection. process_watcher: ?xev.Process = null, - /// Completion structure for process wait callback. - process_completion: xev.Completion = .{}, - /// Context for disambiguating process exit callbacks. + /// Context for disambiguating process exit callbacks. Includes its own completion struct + /// so each process watcher has an independent completion that won't be corrupted on relaunch. process_wait_ctx: ?*WaitContext = null, /// Incremented whenever a new watcher is armed to ignore stale completions. process_generation: usize = 0, @@ -58,6 +60,8 @@ pub const SessionState = struct { generation: usize, pid: posix.pid_t, orphaned: bool = false, + /// Each WaitContext has its own completion to avoid corruption when relaunching. + completion: xev.Completion = .{}, }; pub const InitError = shell_mod.Shell.SpawnError || MakeNonBlockingError || error{ @@ -79,18 +83,16 @@ pub const SessionState = struct { pub fn init( allocator: std.mem.Allocator, - id: usize, + slot_index: usize, shell_path: []const u8, size: pty_mod.winsize, - session_id_z: [:0]const u8, notify_sock: [:0]const u8, ) InitError!SessionState { - var session_id_buf: [16:0]u8 = undefined; - @memcpy(session_id_buf[0..session_id_z.len], session_id_z); - session_id_buf[session_id_z.len] = 0; + const session_id_buf = [_:0]u8{0} ** SESSION_ID_BUF_LEN; return SessionState{ - .id = id, + .slot_index = slot_index, + .id = 0, .shell = null, .terminal = null, .stream = null, @@ -117,6 +119,7 @@ pub const SessionState = struct { // Bump generation to invalidate any stale callbacks from a previous shell; wrapping is intentional. self.process_generation +%= 1; + self.assignNewSessionId(); const shell = try shell_mod.Shell.spawn( self.shell_path, @@ -168,7 +171,7 @@ pub const SessionState = struct { process.wait( loop, - &self.process_completion, + &wait_ctx.completion, WaitContext, wait_ctx, processExitCallback, @@ -188,6 +191,17 @@ pub const SessionState = struct { }; } + fn assignNewSessionId(self: *SessionState) void { + const new_id = next_session_id.fetchAdd(1, .seq_cst); + self.id = new_id; + const written = std.fmt.bufPrint(&self.session_id_z, "{d}", .{new_id}) catch |err| { + log.warn("failed to format session id {d}: {}", .{ new_id, err }); + self.session_id_z[0] = 0; + return; + }; + self.session_id_z[written.len] = 0; + } + pub fn deinit(self: *SessionState, allocator: std.mem.Allocator) void { self.pending_write.deinit(allocator); self.pending_write = .empty; @@ -314,11 +328,15 @@ pub const SessionState = struct { try self.ensureSpawned(); } - pub fn relaunchWithDir(self: *SessionState, working_dir: [:0]const u8, loop_opt: ?*xev.Loop) InitError!void { + pub fn relaunch(self: *SessionState, working_dir: ?[:0]const u8, loop_opt: ?*xev.Loop) InitError!void { self.resetForRespawn(); try self.ensureSpawnedWithDir(working_dir, loop_opt); } + pub fn relaunchWithDir(self: *SessionState, working_dir: [:0]const u8, loop_opt: ?*xev.Loop) InitError!void { + return self.relaunch(working_dir, loop_opt); + } + fn resetForRespawn(self: *SessionState) void { self.clearTerminalSelection(); self.pending_write.clearAndFree(self.allocator); @@ -519,6 +537,31 @@ fn getForegroundPgrp(child_pid: posix.pid_t) ?posix.pid_t { pub const MakeNonBlockingError = posix.FcntlError; +test "SessionState assigns incrementing ids" { + const allocator = std.testing.allocator; + next_session_id.store(0, .seq_cst); + + const size = pty_mod.winsize{ + .ws_row = 24, + .ws_col = 80, + .ws_xpixel = 0, + .ws_ypixel = 0, + }; + const notify_sock: [:0]const u8 = "sock"; + + var first = try SessionState.init(allocator, 0, "/bin/zsh", size, notify_sock); + defer first.deinit(allocator); + first.assignNewSessionId(); + try std.testing.expectEqual(@as(usize, 0), first.id); + try std.testing.expectEqualStrings("0", std.mem.sliceTo(first.session_id_z[0..], 0)); + + var second = try SessionState.init(allocator, 1, "/bin/zsh", size, notify_sock); + defer second.deinit(allocator); + second.assignNewSessionId(); + try std.testing.expectEqual(@as(usize, 1), second.id); + try std.testing.expectEqualStrings("1", std.mem.sliceTo(second.session_id_z[0..], 0)); +} + fn makeNonBlocking(fd: posix.fd_t) MakeNonBlockingError!void { const flags = try posix.fcntl(fd, posix.F.GETFL, 0); var o_flags: posix.O = @bitCast(@as(u32, @intCast(flags))); diff --git a/src/ui/components/help_overlay.zig b/src/ui/components/help_overlay.zig index c6bf432..020a235 100644 --- a/src/ui/components/help_overlay.zig +++ b/src/ui/components/help_overlay.zig @@ -13,7 +13,7 @@ const shortcuts = [_]Shortcut{ .{ .key = "Click terminal", .desc = "Expand to full screen" }, .{ .key = "ESC (hold)", .desc = "Collapse to grid view" }, .{ .key = "⌘↑/↓/←/→", .desc = "Navigate grid" }, - .{ .key = "⌘1–⌘9/⌘0", .desc = "Jump to a terminal" }, + .{ .key = "⌘1–⌘9/⌘0", .desc = "Jump to a grid slot" }, .{ .key = "⌘↵", .desc = "Expand focused terminal" }, .{ .key = "⌘T", .desc = "Open worktree picker" }, .{ .key = "⌘?", .desc = "Open help" }, diff --git a/src/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index 56587da..9d38042 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -24,7 +24,7 @@ const CursorKind = enum { arrow, ibeam, pointer }; pub const SessionInteractionComponent = struct { allocator: std.mem.Allocator, - sessions: []SessionState, + sessions: []*SessionState, views: []SessionViewState, font: *font_mod.Font, arrow_cursor: ?*c.SDL_Cursor = null, @@ -35,7 +35,7 @@ pub const SessionInteractionComponent = struct { pub fn init( allocator: std.mem.Allocator, - sessions: []SessionState, + sessions: []*SessionState, font: *font_mod.Font, ) !*SessionInteractionComponent { const self = try allocator.create(SessionInteractionComponent); @@ -160,7 +160,7 @@ pub const SessionInteractionComponent = struct { if (host.view_mode == .Full and event.button.button == c.SDL_BUTTON_LEFT) { const focused_idx = host.focused_session; if (focused_idx >= self.sessions.len) return false; - const focused = &self.sessions[focused_idx]; + const focused = self.sessions[focused_idx]; const view = &self.views[focused_idx]; if (focused.spawned and focused.terminal != null) { @@ -208,7 +208,7 @@ pub const SessionInteractionComponent = struct { if (host.view_mode == .Full) { const focused_idx = host.focused_session; if (focused_idx < self.sessions.len) { - var focused = &self.sessions[focused_idx]; + var focused = self.sessions[focused_idx]; const view = &self.views[focused_idx]; const pin = fullViewPinFromMouse(focused, view, mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows); @@ -284,7 +284,7 @@ pub const SessionInteractionComponent = struct { ); if (hovered_session) |session_idx| { if (session_idx >= self.sessions.len) return false; - var session = &self.sessions[session_idx]; + var session = self.sessions[session_idx]; const view = &self.views[session_idx]; const ticks_per_notch: isize = SCROLL_LINES_PER_TICK; const wheel_ticks: isize = if (event.wheel.integer_y != 0) @@ -357,7 +357,7 @@ pub const SessionInteractionComponent = struct { if (delta_ms <= 0) return; const delta_time_s: f32 = @as(f32, @floatFromInt(delta_ms)) / 1000.0; - for (self.sessions, 0..) |*session, idx| { + for (self.sessions, 0..) |session, idx| { updateScrollInertia(session, &self.views[idx], delta_time_s); } } @@ -857,7 +857,7 @@ fn calculateHoveredSession( host: *const types.UiHost, ) ?usize { return switch (host.view_mode) { - .Grid => { + .Grid, .GridResizing => { if (mouse_x < 0 or mouse_x >= host.window_w or mouse_y < 0 or mouse_y >= host.window_h) return null;