diff --git a/.idea/.gitignore b/.idea/.gitignore index ab1f416..c962579 100644 --- a/.idea/.gitignore +++ b/.idea/.gitignore @@ -8,3 +8,6 @@ /dataSources.local.xml # Editor-based HTTP Client requests /httpRequests/ + +zigbrains.xml + diff --git a/.idea/zigbrains.xml b/.idea/zigbrains.xml deleted file mode 100644 index cb281f7..0000000 --- a/.idea/zigbrains.xml +++ /dev/null @@ -1,2201 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f47be0f..e3cf4e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -142,7 +142,7 @@ const result = grid_row * GRID_COLS + grid_col; // usize, works correctly - Session teardown can run twice on error paths (errdefer plus outer defer). Keep `SessionState.deinit` idempotent: destroy textures/fonts/watchers, then null pointers and reset flags; in `main.zig` only deinit sessions that were actually constructed. - Running `rg` over the whole $HOME on macOS hits protected directories and can hang/time out; narrow searches to the repo, `/etc`, or specific config paths to avoid permission noise and delays. - Do not keep TOML parser-owned maps after `result.deinit()`: duplicate keys and values into your own storage before freeing the parser arena, or later iteration will segfault. -- `std.mem.span` rejects `[:0]const u8`; use `std.mem.sliceTo(ptr, 0)` when converting C strings to slices. +- `std.mem.span` rejects `[:0]const u8` slices; use `std.mem.sliceTo(ptr, 0)` for `[:0]const u8`, and use `std.mem.span` for `[*c]const u8`/`[*:0]const u8` C pointers. - When copying persisted maps (e.g., `[terminals]`), duplicate both key and value slices; borrowing the parser’s backing memory causes use-after-free crashes. - Terminal cwd persistence is currently macOS-only; other platforms skip saving/restoring terminals to avoid stale directories until cross-platform cwd tracking is implemented. - xev process watchers keep a pointer to the provided userdata; if you reuse a shared struct for multiple spawns, a late callback can read updated fields and wrongly mark a new session dead. Allocate a per-watcher context, free it on teardown or after the callback, and bump a generation counter on spawn/despawn to ignore stale events. diff --git a/docs/architecture.md b/docs/architecture.md index 8d20879..997ff02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,8 +4,8 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi ``` ┌─────────────────────────────────────────────────────────────┐ -│ main.zig │ -│ (application lifetime, frame loop, event dispatch) │ +│ main.zig → app/runtime.zig │ +│ (entrypoint → lifetime, frame loop, event dispatch) │ ├─────────────────────────────────────────────────────────────┤ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │ │ Platform │ │ Input │ │ Notification │ │ @@ -28,7 +28,7 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi ## Runtime Flow -**main.zig** owns application lifetime, window sizing, PTY/session startup, configuration persistence, and the frame loop. Each frame it: +**app/runtime.zig** owns application lifetime, window sizing, PTY/session startup, configuration persistence, and the frame loop, while **main.zig** is a thin entrypoint. Each frame it: 1. Polls SDL events and scales coordinates to render space. 2. Builds a lightweight `UiHost` snapshot and lets `UiRoot` handle events first. @@ -75,7 +75,7 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi ``` src/ -├── main.zig # Entry point, frame loop, event dispatch +├── main.zig # Entry point (delegates to app/runtime.zig) ├── c.zig # C bindings (SDL3, TTF, etc.) ├── colors.zig # Theme and color palette management (ANSI 16/256) ├── config.zig # TOML config persistence @@ -97,7 +97,15 @@ src/ │ └── mapper.zig # Key→bytes encoding, shortcut detection │ ├── app/ -│ └── app_state.zig # ViewMode, AnimationState, SessionStatus +│ ├── app_state.zig # ViewMode, AnimationState, SessionStatus +│ ├── runtime.zig # App lifetime + frame loop +│ ├── layout.zig # Sizing, scaling, hover hit-testing, terminal resize +│ ├── ui_host.zig # UiHost snapshot + mouse context +│ ├── grid_nav.zig # Grid navigation + notifications +│ ├── input_keys.zig # Keyboard input encoding +│ ├── input_text.zig # IME/text input handling +│ ├── terminal_actions.zig # Clipboard, paste, clear +│ └── worktree.zig # Worktree command building + cd helpers │ ├── session/ │ ├── state.zig # SessionState: PTY, terminal, process watcher diff --git a/src/app/grid_nav.zig b/src/app/grid_nav.zig new file mode 100644 index 0000000..ef437a8 --- /dev/null +++ b/src/app/grid_nav.zig @@ -0,0 +1,159 @@ +const app_state = @import("app_state.zig"); +const input = @import("../input/mapper.zig"); +const session_state = @import("../session/state.zig"); +const ui_mod = @import("../ui/mod.zig"); +const xev = @import("xev"); + +const AnimationState = app_state.AnimationState; +const Rect = app_state.Rect; +const SessionState = session_state.SessionState; +const ViewMode = app_state.ViewMode; + +pub fn startCollapseToGrid( + anim_state: *AnimationState, + now: i64, + cell_width_pixels: c_int, + cell_height_pixels: c_int, + render_width: c_int, + render_height: c_int, + grid_cols: usize, +) void { + 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 target_rect = Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + + anim_state.mode = .Collapsing; + anim_state.start_time = now; + anim_state.start_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; + anim_state.target_rect = target_rect; +} + +pub fn gridNotificationBufferSize(grid_cols: usize, grid_rows: usize) usize { + const block_bytes = 3; + const spaces_between_cols = 3; + return grid_rows * grid_cols * block_bytes + grid_rows * (grid_cols - 1) * spaces_between_cols + (grid_rows - 1); +} + +pub fn formatGridNotification(buf: []u8, focused_session: usize, grid_cols: usize, grid_rows: usize) ![]const u8 { + const row = focused_session / grid_cols; + const col = focused_session % grid_cols; + + var offset: usize = 0; + for (0..grid_rows) |r| { + for (0..grid_cols) |col_idx| { + const block = if (r == row and col_idx == col) "■" else "□"; + if (offset + block.len > buf.len) return error.BufferTooSmall; + @memcpy(buf[offset..][0..block.len], block); + offset += block.len; + + if (col_idx < grid_cols - 1) { + const spaces_between_cols = 3; + if (offset + spaces_between_cols > buf.len) return error.BufferTooSmall; + buf[offset] = ' '; + offset += 1; + buf[offset] = ' '; + offset += 1; + buf[offset] = ' '; + offset += 1; + } + } + if (r < grid_rows - 1) { + if (offset + 1 > buf.len) return error.BufferTooSmall; + buf[offset] = '\n'; + offset += 1; + } + } + return buf[0..offset]; +} + +pub fn navigateGrid( + anim_state: *AnimationState, + sessions: []SessionState, + session_interaction: *ui_mod.SessionInteractionComponent, + direction: input.GridNavDirection, + now: i64, + enable_wrapping: bool, + show_animation: bool, + grid_cols: usize, + grid_rows: usize, + loop: *xev.Loop, +) !void { + const current_row: usize = anim_state.focused_session / grid_cols; + const current_col: usize = anim_state.focused_session % grid_cols; + var new_row: usize = current_row; + var new_col: usize = current_col; + var animation_mode: ?ViewMode = null; + var is_wrapping = false; + + switch (direction) { + .up => { + if (current_row > 0) { + new_row = current_row - 1; + } else if (enable_wrapping) { + new_row = grid_rows - 1; + is_wrapping = true; + } + if (show_animation and new_row != current_row) { + animation_mode = if (is_wrapping) .PanningUp else .PanningDown; + } + }, + .down => { + if (current_row < grid_rows - 1) { + new_row = current_row + 1; + } else if (enable_wrapping) { + new_row = 0; + is_wrapping = true; + } + if (show_animation and new_row != current_row) { + animation_mode = if (is_wrapping) .PanningDown else .PanningUp; + } + }, + .left => { + if (current_col > 0) { + new_col = current_col - 1; + } else if (enable_wrapping) { + new_col = grid_cols - 1; + is_wrapping = true; + } + if (show_animation and new_col != current_col) { + animation_mode = if (is_wrapping) .PanningLeft else .PanningRight; + } + }, + .right => { + if (current_col < grid_cols - 1) { + new_col = current_col + 1; + } else if (enable_wrapping) { + new_col = 0; + is_wrapping = true; + } + if (show_animation and new_col != current_col) { + animation_mode = if (is_wrapping) .PanningRight else .PanningLeft; + } + }, + } + + const new_session: usize = new_row * grid_cols + new_col; + if (new_session != anim_state.focused_session) { + if (anim_state.mode == .Full) { + try sessions[new_session].ensureSpawnedWithLoop(loop); + } else if (show_animation) { + try sessions[new_session].ensureSpawnedWithLoop(loop); + } + session_interaction.clearSelection(anim_state.focused_session); + session_interaction.clearSelection(new_session); + + if (animation_mode) |mode| { + anim_state.mode = mode; + anim_state.previous_session = anim_state.focused_session; + anim_state.focused_session = new_session; + anim_state.start_time = now; + } else { + anim_state.focused_session = new_session; + } + } +} diff --git a/src/app/input_keys.zig b/src/app/input_keys.zig new file mode 100644 index 0000000..b029a16 --- /dev/null +++ b/src/app/input_keys.zig @@ -0,0 +1,28 @@ +const ghostty_vt = @import("ghostty-vt"); +const input = @import("../input/mapper.zig"); +const session_state = @import("../session/state.zig"); +const c = @import("../c.zig"); + +const SessionState = session_state.SessionState; + +pub fn isModifierKey(key: c.SDL_Keycode) bool { + return key == c.SDLK_LSHIFT or key == c.SDLK_RSHIFT or + key == c.SDLK_LCTRL or key == c.SDLK_RCTRL or + key == c.SDLK_LALT or key == c.SDLK_RALT or + key == c.SDLK_LGUI or key == c.SDLK_RGUI; +} + +pub fn handleKeyInput(focused: *SessionState, key: c.SDL_Keycode, mod: c.SDL_Keymod) !void { + if (key == c.SDLK_ESCAPE) return; + + const kitty_enabled = if (focused.terminal) |*terminal| + terminal.screens.active.kitty_keyboard.current().int() != 0 + else + false; + + var buf: [16]u8 = undefined; + const n = input.encodeKeyWithMod(key, mod, kitty_enabled, &buf); + if (n > 0) { + try focused.sendInput(buf[0..n]); + } +} diff --git a/src/app/input_text.zig b/src/app/input_text.zig new file mode 100644 index 0000000..bee58e3 --- /dev/null +++ b/src/app/input_text.zig @@ -0,0 +1,91 @@ +const std = @import("std"); +const session_state = @import("../session/state.zig"); +const ui_mod = @import("../ui/mod.zig"); + +const SessionState = session_state.SessionState; + +pub const ImeComposition = struct { + codepoints: usize = 0, + + pub fn reset(self: *ImeComposition) void { + self.codepoints = 0; + } +}; + +pub fn countImeCodepoints(text: []const u8) usize { + return std.unicode.utf8CountCodepoints(text) catch text.len; +} + +fn sendDeleteInput(session: *SessionState, count: usize) !void { + if (count == 0) return; + + var buf: [16]u8 = undefined; + @memset(buf[0..], 0x7f); + + var remaining: usize = count; + while (remaining > 0) { + const chunk: usize = @min(remaining, buf.len); + try session.sendInput(buf[0..chunk]); + remaining -= chunk; + } +} + +pub fn clearImeComposition(session: *SessionState, ime: *ImeComposition) !void { + if (ime.codepoints == 0) return; + if (!session.spawned or session.dead) { + ime.codepoints = 0; + return; + } + + try sendDeleteInput(session, ime.codepoints); + ime.codepoints = 0; +} + +pub fn handleTextEditing( + session: *SessionState, + ime: *ImeComposition, + text_ptr: [*c]const u8, + start: c_int, + length: c_int, + session_interaction: *ui_mod.SessionInteractionComponent, +) !void { + if (!session.spawned or session.dead) return; + if (text_ptr == null) return; + + const text = std.mem.sliceTo(text_ptr, 0); + if (text.len == 0) { + if (ime.codepoints == 0) return; + session_interaction.resetScrollIfNeeded(session.id); + try clearImeComposition(session, ime); + return; + } + + session_interaction.resetScrollIfNeeded(session.id); + const is_committed_text = length == 0 and start == 0; + if (is_committed_text) { + try clearImeComposition(session, ime); + try session.sendInput(text); + return; + } + + try clearImeComposition(session, ime); + try session.sendInput(text); + ime.codepoints = countImeCodepoints(text); +} + +pub fn handleTextInput( + session: *SessionState, + ime: *ImeComposition, + text_ptr: [*c]const u8, + session_interaction: *ui_mod.SessionInteractionComponent, +) !void { + if (!session.spawned or session.dead) return; + if (text_ptr == null) return; + + const text = std.mem.sliceTo(text_ptr, 0); + if (text.len == 0) return; + + session_interaction.resetScrollIfNeeded(session.id); + try clearImeComposition(session, ime); + try session.sendInput(text); +} diff --git a/src/app/layout.zig b/src/app/layout.zig new file mode 100644 index 0000000..6ac62a8 --- /dev/null +++ b/src/app/layout.zig @@ -0,0 +1,181 @@ +const std = @import("std"); +const app_state = @import("app_state.zig"); +const c = @import("../c.zig"); +const font_mod = @import("../font.zig"); +const pty_mod = @import("../pty.zig"); +const renderer_mod = @import("../render/renderer.zig"); +const session_state = @import("../session/state.zig"); +const vt_stream = @import("../vt_stream.zig"); + +const AnimationState = app_state.AnimationState; +const SessionState = session_state.SessionState; + +pub const TerminalSize = struct { + cols: u16, + rows: u16, +}; + +pub fn updateRenderSizes( + window: *c.SDL_Window, + window_w: *c_int, + window_h: *c_int, + render_w: *c_int, + render_h: *c_int, + scale_x: *f32, + scale_y: *f32, +) void { + _ = c.SDL_GetWindowSize(window, window_w, window_h); + _ = c.SDL_GetWindowSizeInPixels(window, render_w, render_h); + scale_x.* = if (window_w.* != 0) @as(f32, @floatFromInt(render_w.*)) / @as(f32, @floatFromInt(window_w.*)) else 1.0; + scale_y.* = if (window_h.* != 0) @as(f32, @floatFromInt(render_h.*)) / @as(f32, @floatFromInt(window_h.*)) else 1.0; +} + +pub fn scaleEventToRender(event: *const c.SDL_Event, scale_x: f32, scale_y: f32) c.SDL_Event { + var e = event.*; + switch (e.type) { + c.SDL_EVENT_MOUSE_BUTTON_DOWN => { + e.button.x *= scale_x; + e.button.y *= scale_y; + }, + c.SDL_EVENT_MOUSE_BUTTON_UP => { + e.button.x *= scale_x; + e.button.y *= scale_y; + }, + c.SDL_EVENT_MOUSE_MOTION => { + e.motion.x *= scale_x; + e.motion.y *= scale_y; + }, + c.SDL_EVENT_MOUSE_WHEEL => { + e.wheel.mouse_x *= scale_x; + e.wheel.mouse_y *= scale_y; + }, + c.SDL_EVENT_DROP_FILE, c.SDL_EVENT_DROP_TEXT, c.SDL_EVENT_DROP_POSITION => { + e.drop.x *= scale_x; + e.drop.y *= scale_y; + }, + else => {}, + } + return e; +} + +pub fn calculateHoveredSession( + mouse_x: c_int, + mouse_y: c_int, + anim_state: *const AnimationState, + cell_width_pixels: c_int, + cell_height_pixels: c_int, + render_width: c_int, + render_height: c_int, + grid_cols: usize, + grid_rows: usize, +) ?usize { + return switch (anim_state.mode) { + .Grid => { + if (mouse_x < 0 or mouse_x >= render_width or + mouse_y < 0 or mouse_y >= render_height) return null; + + const grid_col_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_x, cell_width_pixels))), grid_cols - 1); + const grid_row_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_y, cell_height_pixels))), grid_rows - 1); + return grid_row_idx * grid_cols + grid_col_idx; + }, + .Full, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => anim_state.focused_session, + .Expanding, .Collapsing => { + const rect = anim_state.getCurrentRect(std.time.milliTimestamp()); + if (mouse_x >= rect.x and mouse_x < rect.x + rect.w and + mouse_y >= rect.y and mouse_y < rect.y + rect.h) + { + return anim_state.focused_session; + } + return null; + }, + }; +} + +pub fn calculateTerminalSize(font: *const font_mod.Font, window_width: c_int, window_height: c_int, grid_font_scale: f32) TerminalSize { + const padding = renderer_mod.TERMINAL_PADDING * 2; + const usable_w = @max(0, window_width - padding); + const usable_h = @max(0, window_height - padding); + const scaled_cell_w = @max(1, @as(c_int, @intFromFloat(@as(f32, @floatFromInt(font.cell_width)) * grid_font_scale))); + const scaled_cell_h = @max(1, @as(c_int, @intFromFloat(@as(f32, @floatFromInt(font.cell_height)) * grid_font_scale))); + const cols = @max(1, @divFloor(usable_w, scaled_cell_w)); + const rows = @max(1, @divFloor(usable_h, scaled_cell_h)); + return .{ + .cols = @intCast(cols), + .rows = @intCast(rows), + }; +} + +pub fn calculateGridCellTerminalSize(font: *const font_mod.Font, window_width: c_int, window_height: c_int, grid_font_scale: f32, grid_cols: usize, grid_rows: usize) TerminalSize { + const cell_width = @divFloor(window_width, @as(c_int, @intCast(grid_cols))); + const cell_height = @divFloor(window_height, @as(c_int, @intCast(grid_rows))); + return calculateTerminalSize(font, cell_width, cell_height, grid_font_scale); +} + +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 => { + const grid_dim = @max(grid_cols, grid_rows); + const base_grid_scale: f32 = 1.0 / @as(f32, @floatFromInt(grid_dim)); + const effective_scale: f32 = base_grid_scale * grid_font_scale; + return calculateGridCellTerminalSize(font, window_width, window_height, effective_scale, grid_cols, grid_rows); + }, + else => calculateTerminalSize(font, window_width, window_height, 1.0), + }; +} + +pub fn scaledFontSize(points: c_int, scale: f32) c_int { + const scaled = std.math.round(@as(f32, @floatFromInt(points)) * scale); + return @max(1, @as(c_int, @intFromFloat(scaled))); +} + +pub fn gridFontScaleForMode(mode: app_state.ViewMode, grid_font_scale: f32) f32 { + return switch (mode) { + .Grid, .Expanding, .Collapsing => grid_font_scale, + else => 1.0, + }; +} + +pub fn applyTerminalResize( + sessions: []SessionState, + allocator: std.mem.Allocator, + cols: u16, + rows: u16, + render_width: c_int, + render_height: c_int, +) void { + const usable_width = @max(0, render_width - renderer_mod.TERMINAL_PADDING * 2); + const usable_height = @max(0, render_height - renderer_mod.TERMINAL_PADDING * 2); + + const new_size = pty_mod.winsize{ + .ws_row = rows, + .ws_col = cols, + .ws_xpixel = @intCast(usable_width), + .ws_ypixel = @intCast(usable_height), + }; + + for (sessions) |*session| { + session.pty_size = new_size; + if (session.spawned) { + const shell = &(session.shell orelse continue); + const terminal = &(session.terminal orelse continue); + + shell.pty.setSize(new_size) catch |err| { + std.debug.print("Failed to resize PTY for session {d}: {}\n", .{ session.id, err }); + }; + + terminal.resize(allocator, cols, rows) catch |err| { + std.debug.print("Failed to resize terminal for session {d}: {}\n", .{ session.id, err }); + continue; + }; + + if (session.stream) |*stream| { + stream.handler.deinit(); + stream.handler = vt_stream.Handler.init(terminal, shell); + } else { + session.stream = vt_stream.initStream(allocator, terminal, shell); + } + + session.markDirty(); + } + } +} diff --git a/src/app/runtime.zig b/src/app/runtime.zig new file mode 100644 index 0000000..e821968 --- /dev/null +++ b/src/app/runtime.zig @@ -0,0 +1,1227 @@ +// Main application entry: wires SDL3 rendering, ghostty-vt terminals, PTY-backed +// shells, and the grid/animation system that drives the 3×3 terminal wall UI. +const std = @import("std"); +const builtin = @import("builtin"); +const xev = @import("xev"); +const app_state = @import("app_state.zig"); +const grid_nav = @import("grid_nav.zig"); +const input_keys = @import("input_keys.zig"); +const input_text = @import("input_text.zig"); +const layout = @import("layout.zig"); +const terminal_actions = @import("terminal_actions.zig"); +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 platform = @import("../platform/sdl.zig"); +const macos_input = @import("../platform/macos_input_source.zig"); +const input = @import("../input/mapper.zig"); +const renderer_mod = @import("../render/renderer.zig"); +const pty_mod = @import("../pty.zig"); +const font_mod = @import("../font.zig"); +const font_paths_mod = @import("../font_paths.zig"); +const config_mod = @import("../config.zig"); +const colors_mod = @import("../colors.zig"); +const ui_mod = @import("../ui/mod.zig"); +const font_cache_mod = @import("../font_cache.zig"); +const c = @import("../c.zig"); +const metrics_mod = @import("../metrics.zig"); +const open_url = @import("../os/open.zig"); + +const log = std.log.scoped(.runtime); + +const INITIAL_WINDOW_WIDTH = 1200; +const INITIAL_WINDOW_HEIGHT = 900; +const DEFAULT_FONT_SIZE: c_int = 14; +const MIN_FONT_SIZE: c_int = 8; +const MAX_FONT_SIZE: c_int = 96; +const FONT_STEP: c_int = 1; +const UI_FONT_SIZE: c_int = 18; +const ACTIVE_FRAME_NS: i128 = 16_666_667; +const IDLE_FRAME_NS: i128 = 50_000_000; +const MAX_IDLE_RENDER_GAP_NS: i128 = 250_000_000; +const FOREGROUND_PROCESS_CACHE_MS: i64 = 150; +const Rect = app_state.Rect; +const AnimationState = app_state.AnimationState; +const NotificationQueue = notify.NotificationQueue; +const SessionState = session_state.SessionState; + +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 { + if (self.session_idx != focused_session) { + self.session_idx = focused_session; + self.last_check_ms = 0; + } + if (self.last_check_ms == 0 or now_ms < self.last_check_ms or + now_ms - self.last_check_ms >= FOREGROUND_PROCESS_CACHE_MS) + { + self.value = sessions[focused_session].hasForegroundProcess(); + self.last_check_ms = now_ms; + } + return self.value; + } +}; + +fn countForegroundProcesses(sessions: []const SessionState) usize { + var total: usize = 0; + for (sessions) |*session| { + if (session.hasForegroundProcess()) { + total += 1; + } + } + 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; + } + } + idx = 0; + while (idx < start_idx) : (idx += 1) { + if (!sessions[idx].spawned) { + return idx; + } + } + return null; +} + +fn initSharedFont( + allocator: std.mem.Allocator, + renderer: *c.SDL_Renderer, + cache: *font_cache_mod.FontCache, + size: c_int, +) font_mod.Font.InitError!font_mod.Font { + const faces = cache.get(size) catch |err| switch (err) { + error.FontUnavailable => return error.FontLoadFailed, + error.OutOfMemory => return error.OutOfMemory, + }; + return font_mod.Font.initFromFaces(allocator, renderer, .{ + .regular = faces.regular, + .bold = faces.bold, + .italic = faces.italic, + .bold_italic = faces.bold_italic, + .symbol = faces.symbol, + .emoji = faces.emoji, + }); +} + +fn handleQuitRequest( + sessions: []const SessionState, + confirm: *ui_mod.quit_confirm.QuitConfirmComponent, +) bool { + const running_processes = countForegroundProcesses(sessions); + if (running_processes > 0) { + confirm.show(running_processes); + return false; + } + return true; +} + +pub fn run() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Socket listener relays external "awaiting approval / done" signals from + // shells (or other tools) into the UI thread without blocking rendering. + var notify_queue = NotificationQueue{}; + defer notify_queue.deinit(allocator); + + const notify_sock = try notify.getNotifySocketPath(allocator); + defer allocator.free(notify_sock); + + var notify_stop = std.atomic.Value(bool).init(false); + const notify_thread = try notify.startNotifyThread(allocator, notify_sock, ¬ify_queue, ¬ify_stop); + defer { + notify_stop.store(true, .seq_cst); + notify_thread.join(); + } + + var config = config_mod.Config.load(allocator) catch |err| blk: { + if (err == error.ConfigNotFound) { + std.debug.print("Config not found, creating default config file\n", .{}); + config_mod.Config.createDefaultConfigFile(allocator) catch |create_err| { + std.debug.print("Failed to create default config: {}\n", .{create_err}); + }; + } else { + std.debug.print("Failed to load config: {}, using defaults\n", .{err}); + } + break :blk config_mod.Config{ + .font = .{ .size = DEFAULT_FONT_SIZE }, + .window = .{ + .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); + + var persistence = config_mod.Persistence.load(allocator) catch |err| blk: { + std.debug.print("Failed to load persistence: {}, using defaults\n", .{err}); + var fallback = config_mod.Persistence.init(allocator); + fallback.font_size = config.font.size; + fallback.window = config.window; + break :blk fallback; + }; + defer persistence.deinit(); + 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); + var current_grid_font_scale: f32 = config.grid.font_scale; + const animations_enabled = config.ui.enable_animations; + + const window_pos = if (persistence.window.x >= 0 and persistence.window.y >= 0) + platform.WindowPosition{ .x = persistence.window.x, .y = persistence.window.y } + else + null; + + var sdl = try platform.init( + "ARCHITECT", + persistence.window.width, + persistence.window.height, + window_pos, + config.rendering.vsync, + ); + defer platform.deinit(&sdl); + platform.startTextInput(sdl.window); + defer platform.stopTextInput(sdl.window); + var text_input_active = true; + var input_source_tracker = macos_input.InputSourceTracker.init(); + defer input_source_tracker.deinit(); + if (builtin.os.tag == .macos) { + input_source_tracker.capture() catch |err| { + log.warn("Failed to capture input source: {}", .{err}); + }; + } + + const renderer = sdl.renderer; + + var font_size: c_int = persistence.font_size; + var window_width_points: c_int = sdl.window_w; + var window_height_points: c_int = sdl.window_h; + var render_width: c_int = sdl.render_w; + var render_height: c_int = sdl.render_h; + var scale_x = sdl.scale_x; + var scale_y = sdl.scale_y; + var ui_scale: f32 = @max(scale_x, scale_y); + + var font_paths = try font_paths_mod.FontPaths.init(allocator, config.font.family); + defer font_paths.deinit(); + + var shared_font_cache = font_cache_mod.FontCache.init(allocator); + defer shared_font_cache.deinit(); + shared_font_cache.setPaths( + font_paths.regular, + font_paths.bold, + font_paths.italic, + font_paths.bold_italic, + font_paths.symbol_fallback, + font_paths.emoji_fallback, + ); + + var metrics_storage: metrics_mod.Metrics = metrics_mod.Metrics.init(); + const metrics_ptr: ?*metrics_mod.Metrics = if (config.metrics.enabled) &metrics_storage else null; + metrics_mod.global = metrics_ptr; + + var font = try initSharedFont(allocator, renderer, &shared_font_cache, layout.scaledFontSize(font_size, ui_scale)); + defer font.deinit(); + font.metrics = metrics_ptr; + + var ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, layout.scaledFontSize(UI_FONT_SIZE, ui_scale)); + defer ui_font.deinit(); + + var ui = ui_mod.UiRoot.init(allocator); + defer ui.deinit(renderer); + ui.assets.ui_font = &ui_font; + ui.assets.font_cache = &shared_font_cache; + + 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); + 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 }); + + 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); + + const size = pty_mod.winsize{ + .ws_row = full_rows, + .ws_col = full_cols, + .ws_xpixel = @intCast(usable_width), + .ws_ypixel = @intCast(usable_height), + }; + + const sessions = try allocator.alloc(SessionState, grid_count); + var init_count: usize = 0; + defer { + var i: usize = 0; + while (i < init_count) : (i += 1) { + sessions[i].deinit(allocator); + } + 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); + 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 }); + 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 }); + }; + } + } + + try sessions[0].ensureSpawnedWithLoop(&loop); + + init_count = sessions.len; + + const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); + defer allocator.free(session_ui_info); + + var render_cache = try renderer_mod.RenderCache.init(allocator, grid_count); + defer render_cache.deinit(); + + var foreground_cache = ForegroundProcessCache{}; + + var running = true; + + var anim_state = AnimationState{ + .mode = .Grid, + .focused_session = 0, + .previous_session = 0, + .start_time = 0, + .start_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, + .target_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, + }; + var ime_composition = input_text.ImeComposition{}; + var last_focused_session: usize = anim_state.focused_session; + + const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font); + try ui.register(session_interaction_component.asComponent()); + + const worktree_comp_ptr = try allocator.create(ui_mod.worktree_overlay.WorktreeOverlayComponent); + worktree_comp_ptr.* = .{ .allocator = allocator }; + const worktree_component = ui_mod.UiComponent{ + .ptr = worktree_comp_ptr, + .vtable = &ui_mod.worktree_overlay.WorktreeOverlayComponent.vtable, + .z_index = 1000, + }; + try ui.register(worktree_component); + + const help_comp_ptr = try allocator.create(ui_mod.help_overlay.HelpOverlayComponent); + help_comp_ptr.* = .{ .allocator = allocator }; + const help_component = ui_mod.UiComponent{ + .ptr = help_comp_ptr, + .vtable = &ui_mod.help_overlay.HelpOverlayComponent.vtable, + .z_index = 1000, + }; + try ui.register(help_component); + + const pill_group_component = try ui_mod.pill_group.PillGroupComponent.create(allocator, help_comp_ptr, worktree_comp_ptr); + try ui.register(pill_group_component); + const toast_component = try ui_mod.toast.ToastComponent.init(allocator); + try ui.register(toast_component.asComponent()); + ui.toast_component = toast_component; + const escape_component = try ui_mod.escape_hold.EscapeHoldComponent.init(allocator, &ui_font); + try ui.register(escape_component.asComponent()); + const hotkey_component = try ui_mod.hotkey_indicator.HotkeyIndicatorComponent.init(allocator, &ui_font); + try ui.register(hotkey_component.asComponent()); + ui.hotkey_component = hotkey_component; + const restart_component = try ui_mod.restart_buttons.RestartButtonsComponent.init(allocator); + try ui.register(restart_component.asComponent()); + const quit_confirm_component = try ui_mod.quit_confirm.QuitConfirmComponent.init(allocator); + try ui.register(quit_confirm_component.asComponent()); + const confirm_dialog_component = try ui_mod.confirm_dialog.ConfirmDialogComponent.init(allocator); + try ui.register(confirm_dialog_component.asComponent()); + const global_shortcuts_component = try ui_mod.global_shortcuts.GlobalShortcutsComponent.create(allocator); + try ui.register(global_shortcuts_component); + const cwd_bar_component = try ui_mod.cwd_bar.CwdBarComponent.init(allocator); + try ui.register(cwd_bar_component.asComponent()); + const metrics_overlay_component = try ui_mod.metrics_overlay.MetricsOverlayComponent.init(allocator); + try ui.register(metrics_overlay_component.asComponent()); + + // Main loop: handle SDL input, feed PTY output into terminals, apply async + // notifications, drive animations, and render at ~60 FPS. + var last_render_ns: i128 = 0; + while (running) { + const frame_start_ns: i128 = std.time.nanoTimestamp(); + const now = std.time.milliTimestamp(); + + 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| { + std.debug.print("Failed to clear IME composition: {}\n", .{err}); + }; + ime_composition.reset(); + last_focused_session = anim_state.focused_session; + } + processed_event = true; + var scaled_event = layout.scaleEventToRender(&event, scale_x, scale_y); + const focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); + const host_snapshot = ui_host.makeUiHost( + now, + render_width, + render_height, + ui_scale, + cell_width_pixels, + cell_height_pixels, + grid_cols, + grid_rows, + full_cols, + full_rows, + &anim_state, + sessions, + session_ui_info, + focused_has_foreground_process, + &theme, + ); + var event_ui_host = host_snapshot; + ui_host.applyMouseContext(&ui, &event_ui_host, &scaled_event); + + const ui_consumed = ui.handleEvent(&event_ui_host, &scaled_event); + if (ui_consumed) continue; + + switch (scaled_event.type) { + c.SDL_EVENT_QUIT => { + if (handleQuitRequest(sessions[0..], quit_confirm_component)) { + running = false; + } + }, + c.SDL_EVENT_WINDOW_MOVED => { + window_x = scaled_event.window.data1; + window_y = scaled_event.window.data2; + + persistence.window.x = window_x; + persistence.window.y = window_y; + persistence.save(allocator) catch |err| { + std.debug.print("Failed to save persistence: {}\n", .{err}); + }; + }, + c.SDL_EVENT_WINDOW_RESIZED => { + layout.updateRenderSizes(sdl.window, &window_width_points, &window_height_points, &render_width, &render_height, &scale_x, &scale_y); + const prev_scale = ui_scale; + ui_scale = @max(scale_x, scale_y); + if (ui_scale != prev_scale) { + font.deinit(); + ui_font.deinit(); + font = try initSharedFont(allocator, renderer, &shared_font_cache, layout.scaledFontSize(font_size, ui_scale)); + 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); + 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); + 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))); + + 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 }); + + persistence.window.width = window_width_points; + persistence.window.height = window_height_points; + persistence.window.x = window_x; + persistence.window.y = window_y; + persistence.save(allocator) catch |err| { + std.debug.print("Failed to save persistence: {}\n", .{err}); + }; + }, + c.SDL_EVENT_WINDOW_FOCUS_LOST => { + if (builtin.os.tag == .macos) { + if (text_input_active) { + platform.stopTextInput(sdl.window); + text_input_active = false; + } + } + ime_composition.reset(); + }, + c.SDL_EVENT_WINDOW_FOCUS_GAINED => { + if (builtin.os.tag == .macos) { + input_source_tracker.restore() catch |err| { + log.warn("Failed to restore input source: {}", .{err}); + }; + // Reset text input so macOS restores the per-document input source. + if (text_input_active) { + platform.stopTextInput(sdl.window); + } + platform.startTextInput(sdl.window); + text_input_active = true; + } + }, + c.SDL_EVENT_KEYMAP_CHANGED => { + if (builtin.os.tag == .macos) { + input_source_tracker.capture() catch |err| { + log.warn("Failed to capture input source: {}", .{err}); + }; + } + }, + c.SDL_EVENT_TEXT_INPUT => { + 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]; + input_text.handleTextEditing( + focused, + &ime_composition, + scaled_event.edit.text, + scaled_event.edit.start, + scaled_event.edit.length, + session_interaction_component, + ) catch |err| { + std.debug.print("Edit input failed: {}\n", .{err}); + }; + }, + c.SDL_EVENT_DROP_FILE => { + const drop_path_ptr = scaled_event.drop.data; + if (drop_path_ptr == null) continue; + const drop_path = std.mem.span(drop_path_ptr.?); + if (drop_path.len == 0) continue; + + const mouse_x: c_int = @intFromFloat(scaled_event.drop.x); + const mouse_y: c_int = @intFromFloat(scaled_event.drop.y); + + const hovered_session = layout.calculateHoveredSession( + mouse_x, + mouse_y, + &anim_state, + cell_width_pixels, + cell_height_pixels, + render_width, + render_height, + grid_cols, + grid_rows, + ) orelse continue; + + var session = &sessions[hovered_session]; + try session.ensureSpawnedWithLoop(&loop); + + const escaped = worktree.shellQuotePath(allocator, drop_path) catch |err| { + std.debug.print("Failed to escape dropped path: {}\n", .{err}); + continue; + }; + defer allocator.free(escaped); + + terminal_actions.pasteText(session, allocator, escaped, session_interaction_component) catch |err| switch (err) { + error.NoTerminal => ui.showToast("No terminal to paste into", now), + error.NoShell => ui.showToast("Shell not available", now), + else => std.debug.print("Failed to paste dropped path: {}\n", .{err}), + }; + }, + 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 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) + else + null; + + if (has_gui and !has_blocking_mod and key == c.SDLK_Q) { + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘Q", now); + if (handleQuitRequest(sessions[0..], quit_confirm_component)) { + running = false; + } + continue; + } + + 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]; + + if (!session.spawned) { + continue; + } + + if (session.hasForegroundProcess()) { + confirm_dialog_component.show( + "Delete Terminal?", + "A process is running. Delete anyway?", + "Delete", + "Cancel", + .{ .DespawnSession = session_idx }, + ); + } else { + 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); + } else { + anim_state.mode = .Grid; + } + } + session.deinit(allocator); + session_interaction_component.resetView(session_idx); + session.markDirty(); + } + continue; + } + + if (key == c.SDLK_K and has_gui and !has_blocking_mod) { + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘K", now); + terminal_actions.clearTerminal(focused); + ui.showToast("Cleared terminal", now); + } else if (key == c.SDLK_C and has_gui and !has_blocking_mod) { + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘C", now); + terminal_actions.copySelectionToClipboard(focused, allocator, &ui, now) catch |err| { + std.debug.print("Copy failed: {}\n", .{err}); + }; + } else if (key == c.SDLK_V and has_gui and !has_blocking_mod) { + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘V", now); + terminal_actions.pasteClipboardIntoSession(focused, allocator, &ui, now, session_interaction_component) catch |err| { + std.debug.print("Paste failed: {}\n", .{err}); + }; + } else if (input.fontSizeShortcut(key, mod)) |direction| { + if (config.ui.show_hotkey_feedback) ui.showHotkey(if (direction == .increase) "⌘+" else "⌘-", now); + const delta: c_int = if (direction == .increase) FONT_STEP else -FONT_STEP; + const target_size = std.math.clamp(font_size + delta, MIN_FONT_SIZE, MAX_FONT_SIZE); + + if (target_size != font_size) { + const new_font = try initSharedFont(allocator, renderer, &shared_font_cache, layout.scaledFontSize(target_size, ui_scale)); + font.deinit(); + font = new_font; + 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); + full_cols = term_size.cols; + full_rows = term_size.rows; + layout.applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); + std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); + + persistence.font_size = font_size; + persistence.save(allocator) catch |err| { + std.debug.print("Failed to save persistence: {}\n", .{err}); + }; + } + + var notification_buf: [64]u8 = undefined; + const notification_msg = std.fmt.bufPrint(¬ification_buf, "Font size: {d}pt", .{font_size}) catch "Font size changed"; + 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); + + 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 "⌘?"; + if (config.ui.show_hotkey_feedback) ui.showHotkey(hotkey_label, now); + + if (anim_state.mode == .Grid) { + try sessions[idx].ensureSpawnedWithLoop(&loop); + 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 start_rect = Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; + + anim_state.focused_session = idx; + if (animations_enabled) { + anim_state.mode = .Expanding; + anim_state.start_time = now; + anim_state.start_rect = start_rect; + anim_state.target_rect = target_rect; + } else { + anim_state.mode = .Full; + anim_state.start_time = now; + anim_state.start_rect = target_rect; + anim_state.target_rect = target_rect; + anim_state.previous_session = idx; + } + std.debug.print("Expanding session via hotkey: {d}\n", .{idx}); + } else if (anim_state.mode == .Full and idx != anim_state.focused_session) { + try sessions[idx].ensureSpawnedWithLoop(&loop); + session_interaction_component.clearSelection(anim_state.focused_session); + session_interaction_component.clearSelection(idx); + session_interaction_component.setStatus(idx, .running); + session_interaction_component.setAttention(idx, false); + anim_state.focused_session = 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, idx, grid_cols, grid_rows); + ui.showToast(notification_msg, now); + std.debug.print("Switched to session via hotkey: {d}\n", .{idx}); + } + } else if (input.gridNavShortcut(key, mod)) |direction| { + if (anim_state.mode == .Grid) { + if (config.ui.show_hotkey_feedback) { + const arrow = switch (direction) { + .up => "⌘↑", + .down => "⌘↓", + .left => "⌘←", + .right => "⌘→", + }; + ui.showHotkey(arrow, now); + } + 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}); + } else if (anim_state.mode == .Full) { + if (config.ui.show_hotkey_feedback) { + const arrow = switch (direction) { + .up => "⌘↑", + .down => "⌘↓", + .left => "⌘←", + .right => "⌘→", + }; + ui.showHotkey(arrow, now); + } + 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 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); + ui.showToast(notification_msg, now); + + std.debug.print("Full mode grid nav to session {d}\n", .{anim_state.focused_session}); + } else { + if (focused.spawned and !focused.dead) { + session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); + try input_keys.handleKeyInput(focused, key, mod); + } + } + } 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); + 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 start_rect = Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; + + anim_state.focused_session = clicked_session; + if (animations_enabled) { + anim_state.mode = .Expanding; + anim_state.start_time = now; + anim_state.start_rect = start_rect; + anim_state.target_rect = target_rect; + } else { + anim_state.mode = .Full; + anim_state.start_time = now; + anim_state.start_rect = target_rect; + anim_state.target_rect = target_rect; + anim_state.previous_session = clicked_session; + } + std.debug.print("Expanding session: {d}\n", .{clicked_session}); + } else if (focused.spawned and !focused.dead and !input_keys.isModifierKey(key)) { + session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); + try input_keys.handleKeyInput(focused, key, mod); + } + }, + 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]; + if (focused.spawned and !focused.dead and focused.shell != null) { + const esc_byte: [1]u8 = .{27}; + _ = focused.shell.?.write(&esc_byte) catch |err| { + log.warn("session {d}: failed to send escape key: {}", .{ anim_state.focused_session, err }); + }; + } + std.debug.print("Escape released, sent to terminal\n", .{}); + } + }, + else => {}, + } + } + + try loop.run(.no_wait); + + for (sessions) |*session| { + session.checkAlive(); + try session.processOutput(); + try session.flushPendingWrites(); + session.updateCwd(now); + } + const any_session_dirty = render_cache.anyDirty(sessions); + + var notifications = notify_queue.drainAll(); + 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) }); + } + } + + var focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); + const ui_update_host = ui_host.makeUiHost( + now, + render_width, + render_height, + ui_scale, + cell_width_pixels, + cell_height_pixels, + grid_cols, + grid_rows, + full_cols, + full_rows, + &anim_state, + sessions, + session_ui_info, + focused_has_foreground_process, + &theme, + ); + ui.update(&ui_update_host); + + ui_action_loop: while (ui.popAction()) |action| switch (action) { + .RestartSession => |idx| { + if (idx < sessions.len) { + try sessions[idx].restart(); + session_interaction_component.resetView(idx); + std.debug.print("UI requested restart: {d}\n", .{idx}); + } + }, + .FocusSession => |idx| { + if (anim_state.mode != .Grid) continue; + if (idx >= sessions.len) continue; + + session_interaction_component.clearSelection(anim_state.focused_session); + try sessions[idx].ensureSpawnedWithLoop(&loop); + 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 cell_rect = Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; + + anim_state.focused_session = idx; + if (animations_enabled) { + anim_state.mode = .Expanding; + anim_state.start_time = now; + anim_state.start_rect = cell_rect; + anim_state.target_rect = target_rect; + } else { + anim_state.mode = .Full; + anim_state.start_time = now; + anim_state.start_rect = target_rect; + anim_state.target_rect = target_rect; + anim_state.previous_session = idx; + } + std.debug.print("Expanding session: {d}\n", .{idx}); + }, + .DespawnSession => |idx| { + 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); + } else { + anim_state.mode = .Grid; + } + } + sessions[idx].deinit(allocator); + session_interaction_component.resetView(idx); + sessions[idx].markDirty(); + std.debug.print("UI requested despawn: {d}\n", .{idx}); + } + }, + .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); + } 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); + anim_state.mode = .Grid; + anim_state.start_time = now; + anim_state.start_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; + anim_state.target_rect = Rect{ + .x = grid_col * cell_width_pixels, + .y = grid_row * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }; + } + std.debug.print("UI requested collapse of focused session: {d}\n", .{anim_state.focused_session}); + } + }, + .ConfirmQuit => { + running = false; + }, + .OpenConfig => { + if (config_mod.Config.getConfigPath(allocator)) |config_path| { + defer allocator.free(config_path); + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘,", now); + + const result = switch (builtin.os.tag) { + .macos => blk: { + var child = std.process.Child.init(&.{ "open", "-t", config_path }, allocator); + break :blk child.spawn(); + }, + else => open_url.openUrl(allocator, config_path), + }; + result catch |err| { + std.debug.print("Failed to open config file: {}\n", .{err}); + }; + ui.showToast("Opening config file", now); + } else |err| { + std.debug.print("Failed to get config path: {}\n", .{err}); + } + }, + .SwitchWorktree => |switch_action| { + defer allocator.free(switch_action.path); + if (switch_action.session >= sessions.len) continue; + + var session = &sessions[switch_action.session]; + if (session.hasForegroundProcess()) { + ui.showToast("Stop the running process first", now); + continue; + } + + if (!session.spawned or session.dead) { + ui.showToast("Start the shell first", now); + continue; + } + + worktree.changeSessionDirectory(session, allocator, switch_action.path) catch |err| { + std.debug.print("Failed to change directory for session {d}: {}\n", .{ switch_action.session, err }); + ui.showToast("Could not switch worktree", now); + continue; + }; + + session_interaction_component.setStatus(switch_action.session, .running); + session_interaction_component.setAttention(switch_action.session, false); + ui.showToast("Switched worktree", now); + }, + .CreateWorktree => |create_action| { + 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]; + + if (session.hasForegroundProcess()) { + ui.showToast("Stop the running process first", now); + continue; + } + if (!session.spawned or session.dead) { + ui.showToast("Start the shell first", now); + continue; + } + + const command = worktree.buildCreateWorktreeCommand(allocator, create_action.base_path, create_action.name) catch |err| { + std.debug.print("Failed to build worktree command: {}\n", .{err}); + ui.showToast("Could not create worktree", now); + continue; + }; + defer allocator.free(command); + + session.sendInput(command) catch |err| { + std.debug.print("Failed to send worktree command: {}\n", .{err}); + ui.showToast("Could not create worktree", now); + continue; + }; + + // Update cwd to the new worktree path for UI purposes. + const new_path = std.fs.path.join(allocator, &.{ create_action.base_path, ".architect", create_action.name }) catch null; + if (new_path) |abs| { + session.recordCwd(abs) catch |err| { + log.warn("session {d}: failed to record cwd: {}", .{ create_action.session, err }); + }; + allocator.free(abs); + } + + session_interaction_component.setStatus(create_action.session, .running); + session_interaction_component.setAttention(create_action.session, false); + ui.showToast("Creating worktree…", now); + }, + .RemoveWorktree => |remove_action| { + defer allocator.free(remove_action.path); + if (remove_action.session >= sessions.len) continue; + var session = &sessions[remove_action.session]; + + if (session.hasForegroundProcess()) { + ui.showToast("Stop the running process first", now); + continue; + } + if (!session.spawned or session.dead) { + ui.showToast("Start the shell first", now); + continue; + } + + for (sessions, 0..) |*other_session, idx| { + if (idx == remove_action.session) continue; + if (!other_session.spawned or other_session.dead) continue; + + const other_cwd = other_session.cwd_path orelse continue; + if (std.mem.eql(u8, other_cwd, remove_action.path)) { + ui.showToast("Worktree in use by another session", now); + continue :ui_action_loop; + } + if (std.mem.startsWith(u8, other_cwd, remove_action.path)) { + const suffix = other_cwd[remove_action.path.len..]; + if (suffix.len > 0 and suffix[0] == '/') { + ui.showToast("Worktree in use by another session", now); + continue :ui_action_loop; + } + } + } + + const command = worktree.buildRemoveWorktreeCommand(allocator, remove_action.path) catch |err| { + std.debug.print("Failed to build remove worktree command: {}\n", .{err}); + ui.showToast("Could not remove worktree", now); + continue; + }; + defer allocator.free(command); + + session.sendInput(command) catch |err| { + std.debug.print("Failed to send remove worktree command: {}\n", .{err}); + ui.showToast("Could not remove worktree", now); + continue; + }; + + session_interaction_component.setStatus(remove_action.session, .running); + session_interaction_component.setAttention(remove_action.session, false); + ui.showToast("Removing worktree…", now); + }, + .ToggleMetrics => { + if (config.metrics.enabled) { + metrics_overlay_component.toggle(); + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘⇧M", now); + } else { + ui.showToast("Metrics disabled in config", now); + } + }, + }; + + if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or + anim_state.mode == .PanningLeft or anim_state.mode == .PanningRight or + anim_state.mode == .PanningUp or anim_state.mode == .PanningDown) + { + if (anim_state.isComplete(now)) { + const previous_mode = anim_state.mode; + const next_mode = switch (anim_state.mode) { + .Expanding, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => .Full, + .Collapsing => .Grid, + else => anim_state.mode, + }; + anim_state.mode = next_mode; + if (previous_mode == .Collapsing and next_mode == .Grid and anim_state.focused_session < sessions.len) { + sessions[anim_state.focused_session].markDirty(); + } + std.debug.print("Animation complete, new mode: {s}\n", .{@tagName(anim_state.mode)}); + } + } + + 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); + full_cols = term_size.cols; + full_rows = term_size.rows; + layout.applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); + current_grid_font_scale = desired_font_scale; + std.debug.print("Adjusted terminal size for view mode {s}: scale={d:.2} size={d}x{d}\n", .{ + @tagName(anim_state.mode), + desired_font_scale, + full_cols, + full_rows, + }); + } + + focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); + const ui_render_host = ui_host.makeUiHost( + now, + render_width, + render_height, + ui_scale, + cell_width_pixels, + cell_height_pixels, + grid_cols, + grid_rows, + full_cols, + full_rows, + &anim_state, + sessions, + session_ui_info, + focused_has_foreground_process, + &theme, + ); + + const animating = anim_state.mode != .Grid and anim_state.mode != .Full; + const ui_needs_frame = ui.needsFrame(&ui_render_host); + const last_render_stale = last_render_ns == 0 or (frame_start_ns - last_render_ns) >= MAX_IDLE_RENDER_GAP_NS; + 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); + ui.render(&ui_render_host, renderer); + _ = c.SDL_RenderPresent(renderer); + metrics_mod.increment(.frame_count); + last_render_ns = std.time.nanoTimestamp(); + } + + 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. + const needs_throttle = is_idle or !sdl.vsync_enabled; + if (needs_throttle) { + const target_frame_ns: i128 = if (is_idle) IDLE_FRAME_NS else ACTIVE_FRAME_NS; + const frame_end_ns: i128 = std.time.nanoTimestamp(); + const frame_ns = frame_end_ns - frame_start_ns; + if (frame_ns < target_frame_ns) { + const sleep_ns: u64 = @intCast(target_frame_ns - frame_ns); + std.Thread.sleep(sleep_ns); + } + } + } + + if (builtin.os.tag == .macos) { + persistence.clearTerminals(); + 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| { + std.debug.print("Failed to persist terminal {d}: {}\n", .{ idx, err }); + }; + } + } + } + + persistence.save(allocator) catch |err| { + std.debug.print("Failed to save persistence: {}\n", .{err}); + }; +} + +fn allocZ(allocator: std.mem.Allocator, data: []const u8) ![]u8 { + const buf = try allocator.alloc(u8, data.len + 1); + @memcpy(buf[0..data.len], data); + buf[data.len] = 0; + return buf; +} diff --git a/src/app/terminal_actions.zig b/src/app/terminal_actions.zig new file mode 100644 index 0000000..ad0a58c --- /dev/null +++ b/src/app/terminal_actions.zig @@ -0,0 +1,118 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); +const session_state = @import("../session/state.zig"); +const ui_mod = @import("../ui/mod.zig"); +const c = @import("../c.zig"); + +const SessionState = session_state.SessionState; +const log = std.log.scoped(.terminal_actions); + +pub fn pasteText( + session: *SessionState, + allocator: std.mem.Allocator, + text: []const u8, + session_interaction: *ui_mod.SessionInteractionComponent, +) !void { + if (text.len == 0) return; + + session_interaction.resetScrollIfNeeded(session.id); + + const terminal = session.terminal orelse return error.NoTerminal; + if (session.shell == null) return error.NoShell; + + const opts = ghostty_vt.input.PasteOptions.fromTerminal(&terminal); + const slices = ghostty_vt.input.encodePaste(text, opts) catch |err| switch (err) { + error.MutableRequired => blk: { + const buf = try allocator.dupe(u8, text); + defer allocator.free(buf); + break :blk ghostty_vt.input.encodePaste(buf, opts); + }, + else => return err, + }; + + for (slices) |part| { + if (part.len == 0) continue; + try session.sendInput(part); + } +} + +pub fn clearTerminal(session: *SessionState) void { + const terminal_ptr = session.terminal orelse return; + var terminal = terminal_ptr; + + if (terminal.screens.active_key == .alternate) return; + + terminal.screens.active.clearSelection(); + terminal.eraseDisplay(ghostty_vt.EraseDisplay.scrollback, false); + terminal.eraseDisplay(ghostty_vt.EraseDisplay.complete, false); + session.markDirty(); + + session.sendInput(&[_]u8{0x0C}) catch |err| { + log.warn("session {d}: failed to send clear redraw: {}", .{ session.id, err }); + }; +} + +pub fn copySelectionToClipboard( + session: *SessionState, + allocator: std.mem.Allocator, + ui: *ui_mod.UiRoot, + now: i64, +) !void { + const terminal = session.terminal orelse { + ui.showToast("No terminal to copy from", now); + return; + }; + const screen = terminal.screens.active; + const sel = screen.selection orelse { + ui.showToast("No selection", now); + return; + }; + + const text = try screen.selectionString(allocator, .{ .sel = sel, .trim = true }); + defer allocator.free(text); + + const clipboard_text = try allocator.allocSentinel(u8, text.len, 0); + defer allocator.free(clipboard_text); + @memcpy(clipboard_text[0..text.len], text); + + if (!c.SDL_SetClipboardText(clipboard_text.ptr)) { + ui.showToast("Failed to copy selection", now); + return; + } + + ui.showToast("Copied selection", now); +} + +pub fn pasteClipboardIntoSession( + session: *SessionState, + allocator: std.mem.Allocator, + ui: *ui_mod.UiRoot, + now: i64, + session_interaction: *ui_mod.SessionInteractionComponent, +) !void { + const clip_ptr = c.SDL_GetClipboardText(); + defer c.SDL_free(clip_ptr); + if (clip_ptr == null) { + ui.showToast("Clipboard empty", now); + return; + } + const clip = std.mem.sliceTo(clip_ptr, 0); + if (clip.len == 0) { + ui.showToast("Clipboard empty", now); + return; + } + + pasteText(session, allocator, clip, session_interaction) catch |err| switch (err) { + error.NoTerminal => { + ui.showToast("No terminal to paste into", now); + return; + }, + error.NoShell => { + ui.showToast("Shell not available", now); + return; + }, + else => return err, + }; + + ui.showToast("Pasted clipboard", now); +} diff --git a/src/app/ui_host.zig b/src/app/ui_host.zig new file mode 100644 index 0000000..0922134 --- /dev/null +++ b/src/app/ui_host.zig @@ -0,0 +1,101 @@ +const app_state = @import("app_state.zig"); +const c = @import("../c.zig"); +const colors_mod = @import("../colors.zig"); +const session_state = @import("../session/state.zig"); +const ui_mod = @import("../ui/mod.zig"); + +const AnimationState = app_state.AnimationState; +const Rect = app_state.Rect; +const SessionState = session_state.SessionState; + +pub fn applyMouseContext(ui: *ui_mod.UiRoot, host: *ui_mod.UiHost, event: *const c.SDL_Event) void { + switch (event.type) { + c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { + const mouse_x: c_int = @intFromFloat(event.button.x); + const mouse_y: c_int = @intFromFloat(event.button.y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + c.SDL_EVENT_MOUSE_MOTION => { + const mouse_x: c_int = @intFromFloat(event.motion.x); + const mouse_y: c_int = @intFromFloat(event.motion.y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + c.SDL_EVENT_MOUSE_WHEEL => { + const mouse_x: c_int = @intFromFloat(event.wheel.mouse_x); + const mouse_y: c_int = @intFromFloat(event.wheel.mouse_y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + c.SDL_EVENT_DROP_POSITION => { + const mouse_x: c_int = @intFromFloat(event.drop.x); + const mouse_y: c_int = @intFromFloat(event.drop.y); + host.mouse_x = mouse_x; + host.mouse_y = mouse_y; + host.mouse_has_position = true; + host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); + }, + else => {}, + } +} + +pub fn makeUiHost( + now: i64, + render_width: c_int, + render_height: c_int, + ui_scale: f32, + cell_width_pixels: c_int, + cell_height_pixels: c_int, + grid_cols: usize, + grid_rows: usize, + term_cols: u16, + term_rows: u16, + anim_state: *const AnimationState, + sessions: []const SessionState, + buffer: []ui_mod.SessionUiInfo, + focused_has_foreground_process: bool, + theme: *const colors_mod.Theme, +) ui_mod.UiHost { + for (sessions, 0..) |session, i| { + buffer[i] = .{ + .dead = session.dead, + .spawned = session.spawned, + .cwd_path = session.cwd_path, + .cwd_basename = session.cwd_basename, + }; + } + + 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), + else => null, + }; + + return .{ + .now_ms = now, + .window_w = render_width, + .window_h = render_height, + .ui_scale = ui_scale, + .grid_cols = grid_cols, + .grid_rows = grid_rows, + .cell_w = cell_width_pixels, + .cell_h = cell_height_pixels, + .term_cols = term_cols, + .term_rows = term_rows, + .view_mode = anim_state.mode, + .focused_session = anim_state.focused_session, + .focused_cwd = focused_cwd, + .focused_has_foreground_process = focused_has_foreground_process, + .animating_rect = animating_rect, + .sessions = buffer[0..sessions.len], + .theme = theme, + }; +} diff --git a/src/app/worktree.zig b/src/app/worktree.zig new file mode 100644 index 0000000..af2e63f --- /dev/null +++ b/src/app/worktree.zig @@ -0,0 +1,67 @@ +const std = @import("std"); +const session_state = @import("../session/state.zig"); + +const SessionState = session_state.SessionState; + +fn appendQuotedPath(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, path: []const u8) !void { + try buf.append(allocator, '\''); + for (path) |ch| switch (ch) { + '\'' => try buf.appendSlice(allocator, "'\"'\"'"), + else => try buf.append(allocator, ch), + }; + try buf.append(allocator, '\''); +} + +pub fn shellQuotePath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + + try appendQuotedPath(&buf, allocator, path); + try buf.append(allocator, ' '); + + return buf.toOwnedSlice(allocator); +} + +pub fn changeSessionDirectory(session: *SessionState, allocator: std.mem.Allocator, path: []const u8) !void { + var command: std.ArrayList(u8) = .empty; + defer command.deinit(allocator); + + try command.appendSlice(allocator, "cd -- "); + try appendQuotedPath(&command, allocator, path); + try command.append(allocator, '\n'); + + try session.sendInput(command.items); + try session.recordCwd(path); +} + +pub fn buildCreateWorktreeCommand(allocator: std.mem.Allocator, base_path: []const u8, name: []const u8) ![]u8 { + var cmd: std.ArrayList(u8) = .empty; + errdefer cmd.deinit(allocator); + + try cmd.appendSlice(allocator, "cd -- "); + try appendQuotedPath(&cmd, allocator, base_path); + try cmd.appendSlice(allocator, " && mkdir -p .architect && git worktree add "); + + const target_rel = try std.fmt.allocPrint(allocator, ".architect/{s}", .{name}); + defer allocator.free(target_rel); + + try appendQuotedPath(&cmd, allocator, target_rel); + try cmd.appendSlice(allocator, " -b "); + try appendQuotedPath(&cmd, allocator, name); + try cmd.appendSlice(allocator, " && cd -- "); + try appendQuotedPath(&cmd, allocator, target_rel); + try cmd.appendSlice(allocator, "\n"); + + return cmd.toOwnedSlice(allocator); +} + +pub fn buildRemoveWorktreeCommand(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + var cmd: std.ArrayList(u8) = .empty; + errdefer cmd.deinit(allocator); + + try cmd.appendSlice(allocator, "git worktree remove "); + try appendQuotedPath(&cmd, allocator, path); + try cmd.appendSlice(allocator, "\n"); + + return cmd.toOwnedSlice(allocator); +} diff --git a/src/main.zig b/src/main.zig index 6d930ca..0658276 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,1920 +1,5 @@ -// Main application entry: wires SDL2 rendering, ghostty-vt terminals, PTY-backed -// shells, and the grid/animation system that drives the 3×3 terminal wall UI. -const std = @import("std"); -const builtin = @import("builtin"); -const posix = std.posix; -const xev = @import("xev"); -const app_state = @import("app/app_state.zig"); -const notify = @import("session/notify.zig"); -const session_state = @import("session/state.zig"); -const vt_stream = @import("vt_stream.zig"); -const platform = @import("platform/sdl.zig"); -const macos_input = @import("platform/macos_input_source.zig"); -const input = @import("input/mapper.zig"); -const renderer_mod = @import("render/renderer.zig"); -const shell_mod = @import("shell.zig"); -const pty_mod = @import("pty.zig"); -const font_mod = @import("font.zig"); -const font_paths_mod = @import("font_paths.zig"); -const config_mod = @import("config.zig"); -const colors_mod = @import("colors.zig"); -const ui_mod = @import("ui/mod.zig"); -const font_cache_mod = @import("font_cache.zig"); -const ghostty_vt = @import("ghostty-vt"); -const c = @import("c.zig"); -const metrics_mod = @import("metrics.zig"); -const open_url = @import("os/open.zig"); - -const log = std.log.scoped(.main); - -const INITIAL_WINDOW_WIDTH = 1200; -const INITIAL_WINDOW_HEIGHT = 900; -const DEFAULT_FONT_SIZE: c_int = 14; -const MIN_FONT_SIZE: c_int = 8; -const MAX_FONT_SIZE: c_int = 96; -const FONT_STEP: c_int = 1; -const UI_FONT_SIZE: c_int = 18; -const ACTIVE_FRAME_NS: i128 = 16_666_667; -const IDLE_FRAME_NS: i128 = 50_000_000; -const MAX_IDLE_RENDER_GAP_NS: i128 = 250_000_000; -const FOREGROUND_PROCESS_CACHE_MS: i64 = 150; -const ViewMode = app_state.ViewMode; -const Rect = app_state.Rect; -const AnimationState = app_state.AnimationState; -const NotificationQueue = notify.NotificationQueue; -const SessionState = session_state.SessionState; - -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 { - if (self.session_idx != focused_session) { - self.session_idx = focused_session; - self.last_check_ms = 0; - } - if (self.last_check_ms == 0 or now_ms < self.last_check_ms or - now_ms - self.last_check_ms >= FOREGROUND_PROCESS_CACHE_MS) - { - self.value = sessions[focused_session].hasForegroundProcess(); - self.last_check_ms = now_ms; - } - return self.value; - } -}; - -const ImeComposition = struct { - codepoints: usize = 0, - - fn reset(self: *ImeComposition) void { - self.codepoints = 0; - } -}; - -fn countForegroundProcesses(sessions: []const SessionState) usize { - var total: usize = 0; - for (sessions) |*session| { - if (session.hasForegroundProcess()) { - total += 1; - } - } - 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; - } - } - idx = 0; - while (idx < start_idx) : (idx += 1) { - if (!sessions[idx].spawned) { - return idx; - } - } - return null; -} - -fn initSharedFont( - allocator: std.mem.Allocator, - renderer: *c.SDL_Renderer, - cache: *font_cache_mod.FontCache, - size: c_int, -) font_mod.Font.InitError!font_mod.Font { - const faces = cache.get(size) catch |err| switch (err) { - error.FontUnavailable => return error.FontLoadFailed, - error.OutOfMemory => return error.OutOfMemory, - }; - return font_mod.Font.initFromFaces(allocator, renderer, .{ - .regular = faces.regular, - .bold = faces.bold, - .italic = faces.italic, - .bold_italic = faces.bold_italic, - .symbol = faces.symbol, - .emoji = faces.emoji, - }); -} - -fn handleQuitRequest( - sessions: []const SessionState, - confirm: *ui_mod.quit_confirm.QuitConfirmComponent, -) bool { - const running_processes = countForegroundProcesses(sessions); - if (running_processes > 0) { - confirm.show(running_processes); - return false; - } - return true; -} +const runtime = @import("app/runtime.zig"); pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - // Socket listener relays external "awaiting approval / done" signals from - // shells (or other tools) into the UI thread without blocking rendering. - var notify_queue = NotificationQueue{}; - defer notify_queue.deinit(allocator); - - const notify_sock = try notify.getNotifySocketPath(allocator); - defer allocator.free(notify_sock); - - var notify_stop = std.atomic.Value(bool).init(false); - const notify_thread = try notify.startNotifyThread(allocator, notify_sock, ¬ify_queue, ¬ify_stop); - defer { - notify_stop.store(true, .seq_cst); - notify_thread.join(); - } - - var config = config_mod.Config.load(allocator) catch |err| blk: { - if (err == error.ConfigNotFound) { - std.debug.print("Config not found, creating default config file\n", .{}); - config_mod.Config.createDefaultConfigFile(allocator) catch |create_err| { - std.debug.print("Failed to create default config: {}\n", .{create_err}); - }; - } else { - std.debug.print("Failed to load config: {}, using defaults\n", .{err}); - } - break :blk config_mod.Config{ - .font = .{ .size = DEFAULT_FONT_SIZE }, - .window = .{ - .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); - - var persistence = config_mod.Persistence.load(allocator) catch |err| blk: { - std.debug.print("Failed to load persistence: {}, using defaults\n", .{err}); - var fallback = config_mod.Persistence.init(allocator); - fallback.font_size = config.font.size; - fallback.window = config.window; - break :blk fallback; - }; - defer persistence.deinit(); - 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); - var current_grid_font_scale: f32 = config.grid.font_scale; - const animations_enabled = config.ui.enable_animations; - - const window_pos = if (persistence.window.x >= 0 and persistence.window.y >= 0) - platform.WindowPosition{ .x = persistence.window.x, .y = persistence.window.y } - else - null; - - var sdl = try platform.init( - "ARCHITECT", - persistence.window.width, - persistence.window.height, - window_pos, - config.rendering.vsync, - ); - defer platform.deinit(&sdl); - platform.startTextInput(sdl.window); - defer platform.stopTextInput(sdl.window); - var text_input_active = true; - var input_source_tracker = macos_input.InputSourceTracker.init(); - defer input_source_tracker.deinit(); - if (builtin.os.tag == .macos) { - input_source_tracker.capture() catch |err| { - log.warn("Failed to capture input source: {}", .{err}); - }; - } - - const renderer = sdl.renderer; - - var font_size: c_int = persistence.font_size; - var window_width_points: c_int = sdl.window_w; - var window_height_points: c_int = sdl.window_h; - var render_width: c_int = sdl.render_w; - var render_height: c_int = sdl.render_h; - var scale_x = sdl.scale_x; - var scale_y = sdl.scale_y; - var ui_scale: f32 = @max(scale_x, scale_y); - - var font_paths = try font_paths_mod.FontPaths.init(allocator, config.font.family); - defer font_paths.deinit(); - - var shared_font_cache = font_cache_mod.FontCache.init(allocator); - defer shared_font_cache.deinit(); - shared_font_cache.setPaths( - font_paths.regular, - font_paths.bold, - font_paths.italic, - font_paths.bold_italic, - font_paths.symbol_fallback, - font_paths.emoji_fallback, - ); - - var metrics_storage: metrics_mod.Metrics = metrics_mod.Metrics.init(); - const metrics_ptr: ?*metrics_mod.Metrics = if (config.metrics.enabled) &metrics_storage else null; - metrics_mod.global = metrics_ptr; - - var font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(font_size, ui_scale)); - defer font.deinit(); - font.metrics = metrics_ptr; - - var ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(UI_FONT_SIZE, ui_scale)); - defer ui_font.deinit(); - - var ui = ui_mod.UiRoot.init(allocator); - defer ui.deinit(renderer); - ui.assets.ui_font = &ui_font; - ui.assets.font_cache = &shared_font_cache; - - var window_x: c_int = persistence.window.x; - var window_y: c_int = persistence.window.y; - - const initial_term_size = 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 }); - - 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); - - const size = pty_mod.winsize{ - .ws_row = full_rows, - .ws_col = full_cols, - .ws_xpixel = @intCast(usable_width), - .ws_ypixel = @intCast(usable_height), - }; - - const sessions = try allocator.alloc(SessionState, grid_count); - var init_count: usize = 0; - defer { - var i: usize = 0; - while (i < init_count) : (i += 1) { - sessions[i].deinit(allocator); - } - 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); - 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 }); - 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 }); - }; - } - } - - try sessions[0].ensureSpawnedWithLoop(&loop); - - init_count = sessions.len; - - const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); - defer allocator.free(session_ui_info); - - var render_cache = try renderer_mod.RenderCache.init(allocator, grid_count); - defer render_cache.deinit(); - - var foreground_cache = ForegroundProcessCache{}; - - var running = true; - - var anim_state = AnimationState{ - .mode = .Grid, - .focused_session = 0, - .previous_session = 0, - .start_time = 0, - .start_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, - .target_rect = Rect{ .x = 0, .y = 0, .w = 0, .h = 0 }, - }; - var ime_composition = ImeComposition{}; - var last_focused_session: usize = anim_state.focused_session; - - const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font); - try ui.register(session_interaction_component.asComponent()); - - const worktree_comp_ptr = try allocator.create(ui_mod.worktree_overlay.WorktreeOverlayComponent); - worktree_comp_ptr.* = .{ .allocator = allocator }; - const worktree_component = ui_mod.UiComponent{ - .ptr = worktree_comp_ptr, - .vtable = &ui_mod.worktree_overlay.WorktreeOverlayComponent.vtable, - .z_index = 1000, - }; - try ui.register(worktree_component); - - const help_comp_ptr = try allocator.create(ui_mod.help_overlay.HelpOverlayComponent); - help_comp_ptr.* = .{ .allocator = allocator }; - const help_component = ui_mod.UiComponent{ - .ptr = help_comp_ptr, - .vtable = &ui_mod.help_overlay.HelpOverlayComponent.vtable, - .z_index = 1000, - }; - try ui.register(help_component); - - const pill_group_component = try ui_mod.pill_group.PillGroupComponent.create(allocator, help_comp_ptr, worktree_comp_ptr); - try ui.register(pill_group_component); - const toast_component = try ui_mod.toast.ToastComponent.init(allocator); - try ui.register(toast_component.asComponent()); - ui.toast_component = toast_component; - const escape_component = try ui_mod.escape_hold.EscapeHoldComponent.init(allocator, &ui_font); - try ui.register(escape_component.asComponent()); - const hotkey_component = try ui_mod.hotkey_indicator.HotkeyIndicatorComponent.init(allocator, &ui_font); - try ui.register(hotkey_component.asComponent()); - ui.hotkey_component = hotkey_component; - const restart_component = try ui_mod.restart_buttons.RestartButtonsComponent.init(allocator); - try ui.register(restart_component.asComponent()); - const quit_confirm_component = try ui_mod.quit_confirm.QuitConfirmComponent.init(allocator); - try ui.register(quit_confirm_component.asComponent()); - const confirm_dialog_component = try ui_mod.confirm_dialog.ConfirmDialogComponent.init(allocator); - try ui.register(confirm_dialog_component.asComponent()); - const global_shortcuts_component = try ui_mod.global_shortcuts.GlobalShortcutsComponent.create(allocator); - try ui.register(global_shortcuts_component); - const cwd_bar_component = try ui_mod.cwd_bar.CwdBarComponent.init(allocator); - try ui.register(cwd_bar_component.asComponent()); - const metrics_overlay_component = try ui_mod.metrics_overlay.MetricsOverlayComponent.init(allocator); - try ui.register(metrics_overlay_component.asComponent()); - - // Main loop: handle SDL input, feed PTY output into terminals, apply async - // notifications, drive animations, and render at ~60 FPS. - var last_render_ns: i128 = 0; - while (running) { - const frame_start_ns: i128 = std.time.nanoTimestamp(); - const now = std.time.milliTimestamp(); - - 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; - clearImeComposition(&sessions[previous_session], &ime_composition) catch |err| { - std.debug.print("Failed to clear IME composition: {}\n", .{err}); - }; - ime_composition.reset(); - last_focused_session = anim_state.focused_session; - } - processed_event = true; - var scaled_event = scaleEventToRender(&event, scale_x, scale_y); - const focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); - const ui_host = makeUiHost( - now, - render_width, - render_height, - ui_scale, - cell_width_pixels, - cell_height_pixels, - grid_cols, - grid_rows, - full_cols, - full_rows, - &anim_state, - sessions, - session_ui_info, - focused_has_foreground_process, - &theme, - ); - var event_ui_host = ui_host; - applyMouseContext(&ui, &event_ui_host, &scaled_event); - - const ui_consumed = ui.handleEvent(&event_ui_host, &scaled_event); - if (ui_consumed) continue; - - switch (scaled_event.type) { - c.SDL_EVENT_QUIT => { - if (handleQuitRequest(sessions[0..], quit_confirm_component)) { - running = false; - } - }, - c.SDL_EVENT_WINDOW_MOVED => { - window_x = scaled_event.window.data1; - window_y = scaled_event.window.data2; - - persistence.window.x = window_x; - persistence.window.y = window_y; - persistence.save(allocator) catch |err| { - std.debug.print("Failed to save persistence: {}\n", .{err}); - }; - }, - c.SDL_EVENT_WINDOW_RESIZED => { - updateRenderSizes(sdl.window, &window_width_points, &window_height_points, &render_width, &render_height, &scale_x, &scale_y); - const prev_scale = ui_scale; - ui_scale = @max(scale_x, scale_y); - if (ui_scale != prev_scale) { - font.deinit(); - ui_font.deinit(); - font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(font_size, ui_scale)); - font.metrics = metrics_ptr; - ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(UI_FONT_SIZE, ui_scale)); - ui.assets.ui_font = &ui_font; - const new_term_size = 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; - applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); - } else { - const new_term_size = 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; - 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))); - - 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 }); - - persistence.window.width = window_width_points; - persistence.window.height = window_height_points; - persistence.window.x = window_x; - persistence.window.y = window_y; - persistence.save(allocator) catch |err| { - std.debug.print("Failed to save persistence: {}\n", .{err}); - }; - }, - c.SDL_EVENT_WINDOW_FOCUS_LOST => { - if (builtin.os.tag == .macos) { - if (text_input_active) { - platform.stopTextInput(sdl.window); - text_input_active = false; - } - } - ime_composition.reset(); - }, - c.SDL_EVENT_WINDOW_FOCUS_GAINED => { - if (builtin.os.tag == .macos) { - input_source_tracker.restore() catch |err| { - log.warn("Failed to restore input source: {}", .{err}); - }; - // Reset text input so macOS restores the per-document input source. - if (text_input_active) { - platform.stopTextInput(sdl.window); - } - platform.startTextInput(sdl.window); - text_input_active = true; - } - }, - c.SDL_EVENT_KEYMAP_CHANGED => { - if (builtin.os.tag == .macos) { - input_source_tracker.capture() catch |err| { - log.warn("Failed to capture input source: {}", .{err}); - }; - } - }, - c.SDL_EVENT_TEXT_INPUT => { - const focused = &sessions[anim_state.focused_session]; - 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]; - handleTextEditing( - focused, - &ime_composition, - scaled_event.edit.text, - scaled_event.edit.start, - scaled_event.edit.length, - session_interaction_component, - ) catch |err| { - std.debug.print("Edit input failed: {}\n", .{err}); - }; - }, - c.SDL_EVENT_DROP_FILE => { - const drop_path_ptr = scaled_event.drop.data; - if (drop_path_ptr == null) continue; - const drop_path = std.mem.span(drop_path_ptr.?); - if (drop_path.len == 0) continue; - - const mouse_x: c_int = @intFromFloat(scaled_event.drop.x); - const mouse_y: c_int = @intFromFloat(scaled_event.drop.y); - - const hovered_session = calculateHoveredSession( - mouse_x, - mouse_y, - &anim_state, - cell_width_pixels, - cell_height_pixels, - render_width, - render_height, - grid_cols, - grid_rows, - ) orelse continue; - - var session = &sessions[hovered_session]; - try session.ensureSpawnedWithLoop(&loop); - - const escaped = shellQuotePath(allocator, drop_path) catch |err| { - std.debug.print("Failed to escape dropped path: {}\n", .{err}); - continue; - }; - defer allocator.free(escaped); - - pasteText(session, allocator, escaped, session_interaction_component) catch |err| switch (err) { - error.NoTerminal => ui.showToast("No terminal to paste into", now), - error.NoShell => ui.showToast("Shell not available", now), - else => std.debug.print("Failed to paste dropped path: {}\n", .{err}), - }; - }, - 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 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) - else - null; - - if (has_gui and !has_blocking_mod and key == c.SDLK_Q) { - if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘Q", now); - if (handleQuitRequest(sessions[0..], quit_confirm_component)) { - running = false; - } - continue; - } - - 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]; - - if (!session.spawned) { - continue; - } - - if (session.hasForegroundProcess()) { - confirm_dialog_component.show( - "Delete Terminal?", - "A process is running. Delete anyway?", - "Delete", - "Cancel", - .{ .DespawnSession = session_idx }, - ); - } else { - if (anim_state.mode == .Full) { - if (animations_enabled) { - startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid_cols); - } else { - anim_state.mode = .Grid; - } - } - session.deinit(allocator); - session_interaction_component.resetView(session_idx); - session.markDirty(); - } - continue; - } - - if (key == c.SDLK_K and has_gui and !has_blocking_mod) { - if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘K", now); - clearTerminal(focused); - ui.showToast("Cleared terminal", now); - } else if (key == c.SDLK_C and has_gui and !has_blocking_mod) { - if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘C", now); - copySelectionToClipboard(focused, allocator, &ui, now) catch |err| { - std.debug.print("Copy failed: {}\n", .{err}); - }; - } else if (key == c.SDLK_V and has_gui and !has_blocking_mod) { - if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘V", now); - pasteClipboardIntoSession(focused, allocator, &ui, now, session_interaction_component) catch |err| { - std.debug.print("Paste failed: {}\n", .{err}); - }; - } else if (input.fontSizeShortcut(key, mod)) |direction| { - if (config.ui.show_hotkey_feedback) ui.showHotkey(if (direction == .increase) "⌘+" else "⌘-", now); - const delta: c_int = if (direction == .increase) FONT_STEP else -FONT_STEP; - const target_size = std.math.clamp(font_size + delta, MIN_FONT_SIZE, MAX_FONT_SIZE); - - if (target_size != font_size) { - const new_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(target_size, ui_scale)); - font.deinit(); - font = new_font; - font.metrics = metrics_ptr; - font_size = target_size; - - const term_size = 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; - applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); - std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); - - persistence.font_size = font_size; - persistence.save(allocator) catch |err| { - std.debug.print("Failed to save persistence: {}\n", .{err}); - }; - } - - var notification_buf: [64]u8 = undefined; - const notification_msg = std.fmt.bufPrint(¬ification_buf, "Font size: {d}pt", .{font_size}) catch "Font size changed"; - 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); - - 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 = gridNotificationBufferSize(grid_cols, grid_rows); - const notification_buf = try allocator.alloc(u8, buf_size); - defer allocator.free(notification_buf); - const notification_msg = try 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 "⌘?"; - if (config.ui.show_hotkey_feedback) ui.showHotkey(hotkey_label, now); - - if (anim_state.mode == .Grid) { - try sessions[idx].ensureSpawnedWithLoop(&loop); - 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 start_rect = Rect{ - .x = grid_col * cell_width_pixels, - .y = grid_row * cell_height_pixels, - .w = cell_width_pixels, - .h = cell_height_pixels, - }; - const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; - - anim_state.focused_session = idx; - if (animations_enabled) { - anim_state.mode = .Expanding; - anim_state.start_time = now; - anim_state.start_rect = start_rect; - anim_state.target_rect = target_rect; - } else { - anim_state.mode = .Full; - anim_state.start_time = now; - anim_state.start_rect = target_rect; - anim_state.target_rect = target_rect; - anim_state.previous_session = idx; - } - std.debug.print("Expanding session via hotkey: {d}\n", .{idx}); - } else if (anim_state.mode == .Full and idx != anim_state.focused_session) { - try sessions[idx].ensureSpawnedWithLoop(&loop); - session_interaction_component.clearSelection(anim_state.focused_session); - session_interaction_component.clearSelection(idx); - session_interaction_component.setStatus(idx, .running); - session_interaction_component.setAttention(idx, false); - anim_state.focused_session = idx; - - const buf_size = gridNotificationBufferSize(grid_cols, grid_rows); - const notification_buf = try allocator.alloc(u8, buf_size); - defer allocator.free(notification_buf); - const notification_msg = try formatGridNotification(notification_buf, idx, grid_cols, grid_rows); - ui.showToast(notification_msg, now); - std.debug.print("Switched to session via hotkey: {d}\n", .{idx}); - } - } else if (input.gridNavShortcut(key, mod)) |direction| { - if (anim_state.mode == .Grid) { - if (config.ui.show_hotkey_feedback) { - const arrow = switch (direction) { - .up => "⌘↑", - .down => "⌘↓", - .left => "⌘←", - .right => "⌘→", - }; - ui.showHotkey(arrow, now); - } - try 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}); - } else if (anim_state.mode == .Full) { - if (config.ui.show_hotkey_feedback) { - const arrow = switch (direction) { - .up => "⌘↑", - .down => "⌘↓", - .left => "⌘←", - .right => "⌘→", - }; - ui.showHotkey(arrow, now); - } - try navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, animations_enabled, grid_cols, grid_rows, &loop); - - const buf_size = gridNotificationBufferSize(grid_cols, grid_rows); - const notification_buf = try allocator.alloc(u8, buf_size); - defer allocator.free(notification_buf); - const notification_msg = try 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}); - } else { - if (focused.spawned and !focused.dead) { - session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); - try handleKeyInput(focused, key, mod); - } - } - } 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); - 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 start_rect = Rect{ - .x = grid_col * cell_width_pixels, - .y = grid_row * cell_height_pixels, - .w = cell_width_pixels, - .h = cell_height_pixels, - }; - const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; - - anim_state.focused_session = clicked_session; - if (animations_enabled) { - anim_state.mode = .Expanding; - anim_state.start_time = now; - anim_state.start_rect = start_rect; - anim_state.target_rect = target_rect; - } else { - anim_state.mode = .Full; - anim_state.start_time = now; - anim_state.start_rect = target_rect; - anim_state.target_rect = target_rect; - anim_state.previous_session = clicked_session; - } - std.debug.print("Expanding session: {d}\n", .{clicked_session}); - } else if (focused.spawned and !focused.dead and !isModifierKey(key)) { - session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); - try handleKeyInput(focused, key, mod); - } - }, - 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]; - if (focused.spawned and !focused.dead and focused.shell != null) { - const esc_byte: [1]u8 = .{27}; - _ = focused.shell.?.write(&esc_byte) catch |err| { - log.warn("session {d}: failed to send escape key: {}", .{ anim_state.focused_session, err }); - }; - } - std.debug.print("Escape released, sent to terminal\n", .{}); - } - }, - else => {}, - } - } - - try loop.run(.no_wait); - - for (sessions) |*session| { - session.checkAlive(); - try session.processOutput(); - try session.flushPendingWrites(); - session.updateCwd(now); - } - const any_session_dirty = render_cache.anyDirty(sessions); - - var notifications = notify_queue.drainAll(); - 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) }); - } - } - - var focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); - const ui_update_host = makeUiHost( - now, - render_width, - render_height, - ui_scale, - cell_width_pixels, - cell_height_pixels, - grid_cols, - grid_rows, - full_cols, - full_rows, - &anim_state, - sessions, - session_ui_info, - focused_has_foreground_process, - &theme, - ); - ui.update(&ui_update_host); - - ui_action_loop: while (ui.popAction()) |action| switch (action) { - .RestartSession => |idx| { - if (idx < sessions.len) { - try sessions[idx].restart(); - session_interaction_component.resetView(idx); - std.debug.print("UI requested restart: {d}\n", .{idx}); - } - }, - .FocusSession => |idx| { - if (anim_state.mode != .Grid) continue; - if (idx >= sessions.len) continue; - - session_interaction_component.clearSelection(anim_state.focused_session); - try sessions[idx].ensureSpawnedWithLoop(&loop); - 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 cell_rect = Rect{ - .x = grid_col * cell_width_pixels, - .y = grid_row * cell_height_pixels, - .w = cell_width_pixels, - .h = cell_height_pixels, - }; - const target_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; - - anim_state.focused_session = idx; - if (animations_enabled) { - anim_state.mode = .Expanding; - anim_state.start_time = now; - anim_state.start_rect = cell_rect; - anim_state.target_rect = target_rect; - } else { - anim_state.mode = .Full; - anim_state.start_time = now; - anim_state.start_rect = target_rect; - anim_state.target_rect = target_rect; - anim_state.previous_session = idx; - } - std.debug.print("Expanding session: {d}\n", .{idx}); - }, - .DespawnSession => |idx| { - if (idx < sessions.len) { - if (anim_state.mode == .Full and anim_state.focused_session == idx) { - if (animations_enabled) { - startCollapseToGrid(&anim_state, now, cell_width_pixels, cell_height_pixels, render_width, render_height, grid_cols); - } else { - anim_state.mode = .Grid; - } - } - sessions[idx].deinit(allocator); - session_interaction_component.resetView(idx); - sessions[idx].markDirty(); - std.debug.print("UI requested despawn: {d}\n", .{idx}); - } - }, - .RequestCollapseFocused => { - if (anim_state.mode == .Full) { - if (animations_enabled) { - 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); - anim_state.mode = .Grid; - anim_state.start_time = now; - anim_state.start_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; - anim_state.target_rect = Rect{ - .x = grid_col * cell_width_pixels, - .y = grid_row * cell_height_pixels, - .w = cell_width_pixels, - .h = cell_height_pixels, - }; - } - std.debug.print("UI requested collapse of focused session: {d}\n", .{anim_state.focused_session}); - } - }, - .ConfirmQuit => { - running = false; - }, - .OpenConfig => { - if (config_mod.Config.getConfigPath(allocator)) |config_path| { - defer allocator.free(config_path); - if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘,", now); - - const result = switch (builtin.os.tag) { - .macos => blk: { - var child = std.process.Child.init(&.{ "open", "-t", config_path }, allocator); - break :blk child.spawn(); - }, - else => open_url.openUrl(allocator, config_path), - }; - result catch |err| { - std.debug.print("Failed to open config file: {}\n", .{err}); - }; - ui.showToast("Opening config file", now); - } else |err| { - std.debug.print("Failed to get config path: {}\n", .{err}); - } - }, - .SwitchWorktree => |switch_action| { - defer allocator.free(switch_action.path); - if (switch_action.session >= sessions.len) continue; - - var session = &sessions[switch_action.session]; - if (session.hasForegroundProcess()) { - ui.showToast("Stop the running process first", now); - continue; - } - - if (!session.spawned or session.dead) { - ui.showToast("Start the shell first", now); - continue; - } - - changeSessionDirectory(session, allocator, switch_action.path) catch |err| { - std.debug.print("Failed to change directory for session {d}: {}\n", .{ switch_action.session, err }); - ui.showToast("Could not switch worktree", now); - continue; - }; - - session_interaction_component.setStatus(switch_action.session, .running); - session_interaction_component.setAttention(switch_action.session, false); - ui.showToast("Switched worktree", now); - }, - .CreateWorktree => |create_action| { - 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]; - - if (session.hasForegroundProcess()) { - ui.showToast("Stop the running process first", now); - continue; - } - if (!session.spawned or session.dead) { - ui.showToast("Start the shell first", now); - continue; - } - - const command = buildCreateWorktreeCommand(allocator, create_action.base_path, create_action.name) catch |err| { - std.debug.print("Failed to build worktree command: {}\n", .{err}); - ui.showToast("Could not create worktree", now); - continue; - }; - defer allocator.free(command); - - session.sendInput(command) catch |err| { - std.debug.print("Failed to send worktree command: {}\n", .{err}); - ui.showToast("Could not create worktree", now); - continue; - }; - - // Update cwd to the new worktree path for UI purposes. - const new_path = std.fs.path.join(allocator, &.{ create_action.base_path, ".architect", create_action.name }) catch null; - if (new_path) |abs| { - session.recordCwd(abs) catch |err| { - log.warn("session {d}: failed to record cwd: {}", .{ create_action.session, err }); - }; - allocator.free(abs); - } - - session_interaction_component.setStatus(create_action.session, .running); - session_interaction_component.setAttention(create_action.session, false); - ui.showToast("Creating worktree…", now); - }, - .RemoveWorktree => |remove_action| { - defer allocator.free(remove_action.path); - if (remove_action.session >= sessions.len) continue; - var session = &sessions[remove_action.session]; - - if (session.hasForegroundProcess()) { - ui.showToast("Stop the running process first", now); - continue; - } - if (!session.spawned or session.dead) { - ui.showToast("Start the shell first", now); - continue; - } - - for (sessions, 0..) |*other_session, idx| { - if (idx == remove_action.session) continue; - if (!other_session.spawned or other_session.dead) continue; - - const other_cwd = other_session.cwd_path orelse continue; - if (std.mem.eql(u8, other_cwd, remove_action.path)) { - ui.showToast("Worktree in use by another session", now); - continue :ui_action_loop; - } - if (std.mem.startsWith(u8, other_cwd, remove_action.path)) { - const suffix = other_cwd[remove_action.path.len..]; - if (suffix.len > 0 and suffix[0] == '/') { - ui.showToast("Worktree in use by another session", now); - continue :ui_action_loop; - } - } - } - - const command = buildRemoveWorktreeCommand(allocator, remove_action.path) catch |err| { - std.debug.print("Failed to build remove worktree command: {}\n", .{err}); - ui.showToast("Could not remove worktree", now); - continue; - }; - defer allocator.free(command); - - session.sendInput(command) catch |err| { - std.debug.print("Failed to send remove worktree command: {}\n", .{err}); - ui.showToast("Could not remove worktree", now); - continue; - }; - - session_interaction_component.setStatus(remove_action.session, .running); - session_interaction_component.setAttention(remove_action.session, false); - ui.showToast("Removing worktree…", now); - }, - .ToggleMetrics => { - if (config.metrics.enabled) { - metrics_overlay_component.toggle(); - if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘⇧M", now); - } else { - ui.showToast("Metrics disabled in config", now); - } - }, - }; - - if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or - anim_state.mode == .PanningLeft or anim_state.mode == .PanningRight or - anim_state.mode == .PanningUp or anim_state.mode == .PanningDown) - { - if (anim_state.isComplete(now)) { - const previous_mode = anim_state.mode; - const next_mode = switch (anim_state.mode) { - .Expanding, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => .Full, - .Collapsing => .Grid, - else => anim_state.mode, - }; - anim_state.mode = next_mode; - if (previous_mode == .Collapsing and next_mode == .Grid and anim_state.focused_session < sessions.len) { - sessions[anim_state.focused_session].markDirty(); - } - std.debug.print("Animation complete, new mode: {s}\n", .{@tagName(anim_state.mode)}); - } - } - - const desired_font_scale = gridFontScaleForMode(anim_state.mode, config.grid.font_scale); - if (desired_font_scale != current_grid_font_scale) { - const term_size = 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; - applyTerminalResize(sessions, allocator, full_cols, full_rows, render_width, render_height); - current_grid_font_scale = desired_font_scale; - std.debug.print("Adjusted terminal size for view mode {s}: scale={d:.2} size={d}x{d}\n", .{ - @tagName(anim_state.mode), - desired_font_scale, - full_cols, - full_rows, - }); - } - - focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); - const ui_render_host = makeUiHost( - now, - render_width, - render_height, - ui_scale, - cell_width_pixels, - cell_height_pixels, - grid_cols, - grid_rows, - full_cols, - full_rows, - &anim_state, - sessions, - session_ui_info, - focused_has_foreground_process, - &theme, - ); - - const animating = anim_state.mode != .Grid and anim_state.mode != .Full; - const ui_needs_frame = ui.needsFrame(&ui_render_host); - const last_render_stale = last_render_ns == 0 or (frame_start_ns - last_render_ns) >= MAX_IDLE_RENDER_GAP_NS; - 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); - ui.render(&ui_render_host, renderer); - _ = c.SDL_RenderPresent(renderer); - metrics_mod.increment(.frame_count); - last_render_ns = std.time.nanoTimestamp(); - } - - 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. - const needs_throttle = is_idle or !sdl.vsync_enabled; - if (needs_throttle) { - const target_frame_ns: i128 = if (is_idle) IDLE_FRAME_NS else ACTIVE_FRAME_NS; - const frame_end_ns: i128 = std.time.nanoTimestamp(); - const frame_ns = frame_end_ns - frame_start_ns; - if (frame_ns < target_frame_ns) { - const sleep_ns: u64 = @intCast(target_frame_ns - frame_ns); - std.Thread.sleep(sleep_ns); - } - } - } - - if (builtin.os.tag == .macos) { - persistence.clearTerminals(); - 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| { - std.debug.print("Failed to persist terminal {d}: {}\n", .{ idx, err }); - }; - } - } - } - - persistence.save(allocator) catch |err| { - std.debug.print("Failed to save persistence: {}\n", .{err}); - }; -} - -fn allocZ(allocator: std.mem.Allocator, data: []const u8) ![]u8 { - const buf = try allocator.alloc(u8, data.len + 1); - @memcpy(buf[0..data.len], data); - buf[data.len] = 0; - return buf; -} - -fn startCollapseToGrid( - anim_state: *AnimationState, - now: i64, - cell_width_pixels: c_int, - cell_height_pixels: c_int, - render_width: c_int, - render_height: c_int, - grid_cols: usize, -) void { - 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 target_rect = Rect{ - .x = grid_col * cell_width_pixels, - .y = grid_row * cell_height_pixels, - .w = cell_width_pixels, - .h = cell_height_pixels, - }; - - anim_state.mode = .Collapsing; - anim_state.start_time = now; - anim_state.start_rect = Rect{ .x = 0, .y = 0, .w = render_width, .h = render_height }; - anim_state.target_rect = target_rect; -} - -fn gridNotificationBufferSize(grid_cols: usize, grid_rows: usize) usize { - const block_bytes = 3; - const spaces_between_cols = 3; - return grid_rows * grid_cols * block_bytes + grid_rows * (grid_cols - 1) * spaces_between_cols + (grid_rows - 1); -} - -fn formatGridNotification(buf: []u8, focused_session: usize, grid_cols: usize, grid_rows: usize) ![]const u8 { - const row = focused_session / grid_cols; - const col = focused_session % grid_cols; - - var offset: usize = 0; - for (0..grid_rows) |r| { - for (0..grid_cols) |col_idx| { - const block = if (r == row and col_idx == col) "■" else "□"; - if (offset + block.len > buf.len) return error.BufferTooSmall; - @memcpy(buf[offset..][0..block.len], block); - offset += block.len; - - if (col_idx < grid_cols - 1) { - const spaces_between_cols = 3; - if (offset + spaces_between_cols > buf.len) return error.BufferTooSmall; - buf[offset] = ' '; - offset += 1; - buf[offset] = ' '; - offset += 1; - buf[offset] = ' '; - offset += 1; - } - } - if (r < grid_rows - 1) { - if (offset + 1 > buf.len) return error.BufferTooSmall; - buf[offset] = '\n'; - offset += 1; - } - } - return buf[0..offset]; -} - -fn navigateGrid( - anim_state: *AnimationState, - sessions: []SessionState, - session_interaction: *ui_mod.SessionInteractionComponent, - direction: input.GridNavDirection, - now: i64, - enable_wrapping: bool, - show_animation: bool, - grid_cols: usize, - grid_rows: usize, - loop: *xev.Loop, -) !void { - const current_row: usize = anim_state.focused_session / grid_cols; - const current_col: usize = anim_state.focused_session % grid_cols; - var new_row: usize = current_row; - var new_col: usize = current_col; - var animation_mode: ?ViewMode = null; - var is_wrapping = false; - - switch (direction) { - .up => { - if (current_row > 0) { - new_row = current_row - 1; - } else if (enable_wrapping) { - new_row = grid_rows - 1; - is_wrapping = true; - } - if (show_animation and new_row != current_row) { - animation_mode = if (is_wrapping) .PanningUp else .PanningDown; - } - }, - .down => { - if (current_row < grid_rows - 1) { - new_row = current_row + 1; - } else if (enable_wrapping) { - new_row = 0; - is_wrapping = true; - } - if (show_animation and new_row != current_row) { - animation_mode = if (is_wrapping) .PanningDown else .PanningUp; - } - }, - .left => { - if (current_col > 0) { - new_col = current_col - 1; - } else if (enable_wrapping) { - new_col = grid_cols - 1; - is_wrapping = true; - } - if (show_animation and new_col != current_col) { - animation_mode = if (is_wrapping) .PanningLeft else .PanningRight; - } - }, - .right => { - if (current_col < grid_cols - 1) { - new_col = current_col + 1; - } else if (enable_wrapping) { - new_col = 0; - is_wrapping = true; - } - if (show_animation and new_col != current_col) { - animation_mode = if (is_wrapping) .PanningRight else .PanningLeft; - } - }, - } - - const new_session: usize = new_row * grid_cols + new_col; - if (new_session != anim_state.focused_session) { - if (anim_state.mode == .Full) { - try sessions[new_session].ensureSpawnedWithLoop(loop); - } else if (show_animation) { - try sessions[new_session].ensureSpawnedWithLoop(loop); - } - session_interaction.clearSelection(anim_state.focused_session); - session_interaction.clearSelection(new_session); - - if (animation_mode) |mode| { - anim_state.mode = mode; - anim_state.previous_session = anim_state.focused_session; - anim_state.focused_session = new_session; - anim_state.start_time = now; - } else { - anim_state.focused_session = new_session; - } - } -} - -fn updateRenderSizes( - window: *c.SDL_Window, - window_w: *c_int, - window_h: *c_int, - render_w: *c_int, - render_h: *c_int, - scale_x: *f32, - scale_y: *f32, -) void { - _ = c.SDL_GetWindowSize(window, window_w, window_h); - _ = c.SDL_GetWindowSizeInPixels(window, render_w, render_h); - scale_x.* = if (window_w.* != 0) @as(f32, @floatFromInt(render_w.*)) / @as(f32, @floatFromInt(window_w.*)) else 1.0; - scale_y.* = if (window_h.* != 0) @as(f32, @floatFromInt(render_h.*)) / @as(f32, @floatFromInt(window_h.*)) else 1.0; -} - -fn scaleEventToRender(event: *const c.SDL_Event, scale_x: f32, scale_y: f32) c.SDL_Event { - var e = event.*; - switch (e.type) { - c.SDL_EVENT_MOUSE_BUTTON_DOWN => { - e.button.x *= scale_x; - e.button.y *= scale_y; - }, - c.SDL_EVENT_MOUSE_BUTTON_UP => { - e.button.x *= scale_x; - e.button.y *= scale_y; - }, - c.SDL_EVENT_MOUSE_MOTION => { - e.motion.x *= scale_x; - e.motion.y *= scale_y; - }, - c.SDL_EVENT_MOUSE_WHEEL => { - e.wheel.mouse_x *= scale_x; - e.wheel.mouse_y *= scale_y; - }, - c.SDL_EVENT_DROP_FILE, c.SDL_EVENT_DROP_TEXT, c.SDL_EVENT_DROP_POSITION => { - e.drop.x *= scale_x; - e.drop.y *= scale_y; - }, - else => {}, - } - return e; -} - -fn applyMouseContext(ui: *ui_mod.UiRoot, host: *ui_mod.UiHost, event: *const c.SDL_Event) void { - switch (event.type) { - c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { - const mouse_x: c_int = @intFromFloat(event.button.x); - const mouse_y: c_int = @intFromFloat(event.button.y); - host.mouse_x = mouse_x; - host.mouse_y = mouse_y; - host.mouse_has_position = true; - host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); - }, - c.SDL_EVENT_MOUSE_MOTION => { - const mouse_x: c_int = @intFromFloat(event.motion.x); - const mouse_y: c_int = @intFromFloat(event.motion.y); - host.mouse_x = mouse_x; - host.mouse_y = mouse_y; - host.mouse_has_position = true; - host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); - }, - c.SDL_EVENT_MOUSE_WHEEL => { - const mouse_x: c_int = @intFromFloat(event.wheel.mouse_x); - const mouse_y: c_int = @intFromFloat(event.wheel.mouse_y); - host.mouse_x = mouse_x; - host.mouse_y = mouse_y; - host.mouse_has_position = true; - host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); - }, - c.SDL_EVENT_DROP_POSITION => { - const mouse_x: c_int = @intFromFloat(event.drop.x); - const mouse_y: c_int = @intFromFloat(event.drop.y); - host.mouse_x = mouse_x; - host.mouse_y = mouse_y; - host.mouse_has_position = true; - host.mouse_over_ui = ui.hitTest(host, mouse_x, mouse_y); - }, - else => {}, - } -} - -fn makeUiHost( - now: i64, - render_width: c_int, - render_height: c_int, - ui_scale: f32, - cell_width_pixels: c_int, - cell_height_pixels: c_int, - grid_cols: usize, - grid_rows: usize, - term_cols: u16, - term_rows: u16, - anim_state: *const AnimationState, - sessions: []const SessionState, - buffer: []ui_mod.SessionUiInfo, - focused_has_foreground_process: bool, - theme: *const colors_mod.Theme, -) ui_mod.UiHost { - for (sessions, 0..) |session, i| { - buffer[i] = .{ - .dead = session.dead, - .spawned = session.spawned, - .cwd_path = session.cwd_path, - .cwd_basename = session.cwd_basename, - }; - } - - 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), - else => null, - }; - - return .{ - .now_ms = now, - .window_w = render_width, - .window_h = render_height, - .ui_scale = ui_scale, - .grid_cols = grid_cols, - .grid_rows = grid_rows, - .cell_w = cell_width_pixels, - .cell_h = cell_height_pixels, - .term_cols = term_cols, - .term_rows = term_rows, - .view_mode = anim_state.mode, - .focused_session = anim_state.focused_session, - .focused_cwd = focused_cwd, - .focused_has_foreground_process = focused_has_foreground_process, - .animating_rect = animating_rect, - .sessions = buffer[0..sessions.len], - .theme = theme, - }; -} - -fn calculateHoveredSession( - mouse_x: c_int, - mouse_y: c_int, - anim_state: *const AnimationState, - cell_width_pixels: c_int, - cell_height_pixels: c_int, - render_width: c_int, - render_height: c_int, - grid_cols: usize, - grid_rows: usize, -) ?usize { - return switch (anim_state.mode) { - .Grid => { - if (mouse_x < 0 or mouse_x >= render_width or - mouse_y < 0 or mouse_y >= render_height) return null; - - const grid_col_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_x, cell_width_pixels))), grid_cols - 1); - const grid_row_idx: usize = @min(@as(usize, @intCast(@divFloor(mouse_y, cell_height_pixels))), grid_rows - 1); - return grid_row_idx * grid_cols + grid_col_idx; - }, - .Full, .PanningLeft, .PanningRight, .PanningUp, .PanningDown => anim_state.focused_session, - .Expanding, .Collapsing => { - const rect = anim_state.getCurrentRect(std.time.milliTimestamp()); - if (mouse_x >= rect.x and mouse_x < rect.x + rect.w and - mouse_y >= rect.y and mouse_y < rect.y + rect.h) - { - return anim_state.focused_session; - } - return null; - }, - }; -} - -const TerminalSize = struct { - cols: u16, - rows: u16, -}; - -fn calculateTerminalSize(font: *const font_mod.Font, window_width: c_int, window_height: c_int, grid_font_scale: f32) TerminalSize { - const padding = renderer_mod.TERMINAL_PADDING * 2; - const usable_w = @max(0, window_width - padding); - const usable_h = @max(0, window_height - padding); - const scaled_cell_w = @max(1, @as(c_int, @intFromFloat(@as(f32, @floatFromInt(font.cell_width)) * grid_font_scale))); - const scaled_cell_h = @max(1, @as(c_int, @intFromFloat(@as(f32, @floatFromInt(font.cell_height)) * grid_font_scale))); - const cols = @max(1, @divFloor(usable_w, scaled_cell_w)); - const rows = @max(1, @divFloor(usable_h, scaled_cell_h)); - return .{ - .cols = @intCast(cols), - .rows = @intCast(rows), - }; -} - -fn calculateGridCellTerminalSize(font: *const font_mod.Font, window_width: c_int, window_height: c_int, grid_font_scale: f32, grid_cols: usize, grid_rows: usize) TerminalSize { - const cell_width = @divFloor(window_width, @as(c_int, @intCast(grid_cols))); - const cell_height = @divFloor(window_height, @as(c_int, @intCast(grid_rows))); - return calculateTerminalSize(font, cell_width, cell_height, grid_font_scale); -} - -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 => { - const grid_dim = @max(grid_cols, grid_rows); - const base_grid_scale: f32 = 1.0 / @as(f32, @floatFromInt(grid_dim)); - const effective_scale: f32 = base_grid_scale * grid_font_scale; - return calculateGridCellTerminalSize(font, window_width, window_height, effective_scale, grid_cols, grid_rows); - }, - else => calculateTerminalSize(font, window_width, window_height, 1.0), - }; -} - -fn scaledFontSize(points: c_int, scale: f32) c_int { - const scaled = std.math.round(@as(f32, @floatFromInt(points)) * scale); - return @max(1, @as(c_int, @intFromFloat(scaled))); -} - -fn gridFontScaleForMode(mode: app_state.ViewMode, grid_font_scale: f32) f32 { - return switch (mode) { - .Grid, .Expanding, .Collapsing => grid_font_scale, - else => 1.0, - }; -} - -fn applyTerminalResize( - sessions: []SessionState, - allocator: std.mem.Allocator, - cols: u16, - rows: u16, - render_width: c_int, - render_height: c_int, -) void { - const usable_width = @max(0, render_width - renderer_mod.TERMINAL_PADDING * 2); - const usable_height = @max(0, render_height - renderer_mod.TERMINAL_PADDING * 2); - - const new_size = pty_mod.winsize{ - .ws_row = rows, - .ws_col = cols, - .ws_xpixel = @intCast(usable_width), - .ws_ypixel = @intCast(usable_height), - }; - - for (sessions) |*session| { - session.pty_size = new_size; - if (session.spawned) { - const shell = &(session.shell orelse continue); - const terminal = &(session.terminal orelse continue); - - shell.pty.setSize(new_size) catch |err| { - std.debug.print("Failed to resize PTY for session {d}: {}\n", .{ session.id, err }); - }; - - terminal.resize(allocator, cols, rows) catch |err| { - std.debug.print("Failed to resize terminal for session {d}: {}\n", .{ session.id, err }); - continue; - }; - - if (session.stream) |*stream| { - stream.handler.deinit(); - stream.handler = vt_stream.Handler.init(terminal, shell); - } else { - session.stream = vt_stream.initStream(allocator, terminal, shell); - } - - session.markDirty(); - } - } -} - -fn isModifierKey(key: c.SDL_Keycode) bool { - return key == c.SDLK_LSHIFT or key == c.SDLK_RSHIFT or - key == c.SDLK_LCTRL or key == c.SDLK_RCTRL or - key == c.SDLK_LALT or key == c.SDLK_RALT or - key == c.SDLK_LGUI or key == c.SDLK_RGUI; -} - -fn handleKeyInput(focused: *SessionState, key: c.SDL_Keycode, mod: c.SDL_Keymod) !void { - if (key == c.SDLK_ESCAPE) return; - - // Check if kitty keyboard protocol is enabled (any non-zero flags value) - const kitty_enabled = if (focused.terminal) |*terminal| - terminal.screens.active.kitty_keyboard.current().int() != 0 - else - false; - - var buf: [16]u8 = undefined; - const n = input.encodeKeyWithMod(key, mod, kitty_enabled, &buf); - if (n > 0) { - try focused.sendInput(buf[0..n]); - } -} - -fn appendQuotedPath(buf: *std.ArrayList(u8), allocator: std.mem.Allocator, path: []const u8) !void { - try buf.append(allocator, '\''); - for (path) |ch| switch (ch) { - '\'' => try buf.appendSlice(allocator, "'\"'\"'"), - else => try buf.append(allocator, ch), - }; - try buf.append(allocator, '\''); -} - -fn shellQuotePath(allocator: std.mem.Allocator, path: []const u8) ![]u8 { - var buf: std.ArrayList(u8) = .empty; - errdefer buf.deinit(allocator); - - try appendQuotedPath(&buf, allocator, path); - try buf.append(allocator, ' '); - - return buf.toOwnedSlice(allocator); -} - -fn changeSessionDirectory(session: *SessionState, allocator: std.mem.Allocator, path: []const u8) !void { - var command: std.ArrayList(u8) = .empty; - defer command.deinit(allocator); - - try command.appendSlice(allocator, "cd -- "); - try appendQuotedPath(&command, allocator, path); - try command.append(allocator, '\n'); - - try session.sendInput(command.items); - try session.recordCwd(path); -} - -fn buildCreateWorktreeCommand(allocator: std.mem.Allocator, base_path: []const u8, name: []const u8) ![]u8 { - var cmd: std.ArrayList(u8) = .empty; - errdefer cmd.deinit(allocator); - - try cmd.appendSlice(allocator, "cd -- "); - try appendQuotedPath(&cmd, allocator, base_path); - try cmd.appendSlice(allocator, " && mkdir -p .architect && git worktree add "); - - const target_rel = try std.fmt.allocPrint(allocator, ".architect/{s}", .{name}); - defer allocator.free(target_rel); - - try appendQuotedPath(&cmd, allocator, target_rel); - try cmd.appendSlice(allocator, " -b "); - try appendQuotedPath(&cmd, allocator, name); - try cmd.appendSlice(allocator, " && cd -- "); - try appendQuotedPath(&cmd, allocator, target_rel); - try cmd.appendSlice(allocator, "\n"); - - return cmd.toOwnedSlice(allocator); -} - -fn buildRemoveWorktreeCommand(allocator: std.mem.Allocator, path: []const u8) ![]u8 { - var cmd: std.ArrayList(u8) = .empty; - errdefer cmd.deinit(allocator); - - try cmd.appendSlice(allocator, "git worktree remove "); - try appendQuotedPath(&cmd, allocator, path); - try cmd.appendSlice(allocator, "\n"); - - return cmd.toOwnedSlice(allocator); -} - -fn pasteText( - session: *SessionState, - allocator: std.mem.Allocator, - text: []const u8, - session_interaction: *ui_mod.SessionInteractionComponent, -) !void { - if (text.len == 0) return; - - session_interaction.resetScrollIfNeeded(session.id); - - const terminal = session.terminal orelse return error.NoTerminal; - if (session.shell == null) return error.NoShell; - - const opts = ghostty_vt.input.PasteOptions.fromTerminal(&terminal); - const slices = ghostty_vt.input.encodePaste(text, opts) catch |err| switch (err) { - error.MutableRequired => blk: { - const buf = try allocator.dupe(u8, text); - defer allocator.free(buf); - break :blk ghostty_vt.input.encodePaste(buf, opts); - }, - else => return err, - }; - - for (slices) |part| { - if (part.len == 0) continue; - try session.sendInput(part); - } -} - -fn countImeCodepoints(text: []const u8) usize { - return std.unicode.utf8CountCodepoints(text) catch text.len; -} - -fn sendDeleteInput(session: *SessionState, count: usize) !void { - if (count == 0) return; - - var buf: [16]u8 = undefined; - @memset(buf[0..], 0x7f); - - var remaining: usize = count; - while (remaining > 0) { - const chunk: usize = @min(remaining, buf.len); - try session.sendInput(buf[0..chunk]); - remaining -= chunk; - } -} - -fn clearImeComposition(session: *SessionState, ime: *ImeComposition) !void { - if (ime.codepoints == 0) return; - if (!session.spawned or session.dead) { - ime.codepoints = 0; - return; - } - - try sendDeleteInput(session, ime.codepoints); - ime.codepoints = 0; -} - -fn handleTextEditing( - session: *SessionState, - ime: *ImeComposition, - text_ptr: [*c]const u8, - start: c_int, - length: c_int, - session_interaction: *ui_mod.SessionInteractionComponent, -) !void { - if (!session.spawned or session.dead) return; - if (text_ptr == null) return; - - const text = std.mem.sliceTo(text_ptr, 0); - if (text.len == 0) { - if (ime.codepoints == 0) return; - session_interaction.resetScrollIfNeeded(session.id); - try clearImeComposition(session, ime); - return; - } - - session_interaction.resetScrollIfNeeded(session.id); - const is_committed_text = length == 0 and start == 0; - if (is_committed_text) { - try clearImeComposition(session, ime); - try session.sendInput(text); - return; - } - - try clearImeComposition(session, ime); - try session.sendInput(text); - ime.codepoints = countImeCodepoints(text); -} - -fn handleTextInput( - session: *SessionState, - ime: *ImeComposition, - text_ptr: [*c]const u8, - session_interaction: *ui_mod.SessionInteractionComponent, -) !void { - if (!session.spawned or session.dead) return; - if (text_ptr == null) return; - - const text = std.mem.sliceTo(text_ptr, 0); - if (text.len == 0) return; - - session_interaction.resetScrollIfNeeded(session.id); - try clearImeComposition(session, ime); - try session.sendInput(text); -} - -fn clearTerminal(session: *SessionState) void { - const terminal_ptr = session.terminal orelse return; - var terminal = terminal_ptr; - - // Match Ghostty behavior: avoid clearing alt screen to not disrupt full-screen apps. - if (terminal.screens.active_key == .alternate) return; - - terminal.screens.active.clearSelection(); - terminal.eraseDisplay(ghostty_vt.EraseDisplay.scrollback, false); - terminal.eraseDisplay(ghostty_vt.EraseDisplay.complete, false); - session.markDirty(); - - // Trigger shell redraw like Ghostty (FF) so the prompt is repainted at top. - session.sendInput(&[_]u8{0x0C}) catch |err| { - log.warn("session {d}: failed to send clear redraw: {}", .{ session.id, err }); - }; -} - -fn copySelectionToClipboard( - session: *SessionState, - allocator: std.mem.Allocator, - ui: *ui_mod.UiRoot, - now: i64, -) !void { - const terminal = session.terminal orelse { - ui.showToast("No terminal to copy from", now); - return; - }; - const screen = terminal.screens.active; - const sel = screen.selection orelse { - ui.showToast("No selection", now); - return; - }; - - const text = try screen.selectionString(allocator, .{ .sel = sel, .trim = true }); - defer allocator.free(text); - - const clipboard_text = try allocator.allocSentinel(u8, text.len, 0); - defer allocator.free(clipboard_text); - @memcpy(clipboard_text[0..text.len], text); - - if (!c.SDL_SetClipboardText(clipboard_text.ptr)) { - ui.showToast("Failed to copy selection", now); - return; - } - - ui.showToast("Copied selection", now); -} - -fn pasteClipboardIntoSession( - session: *SessionState, - allocator: std.mem.Allocator, - ui: *ui_mod.UiRoot, - now: i64, - session_interaction: *ui_mod.SessionInteractionComponent, -) !void { - const clip_ptr = c.SDL_GetClipboardText(); - defer c.SDL_free(clip_ptr); - if (clip_ptr == null) { - ui.showToast("Clipboard empty", now); - return; - } - const clip = std.mem.sliceTo(clip_ptr, 0); - if (clip.len == 0) { - ui.showToast("Clipboard empty", now); - return; - } - - pasteText(session, allocator, clip, session_interaction) catch |err| switch (err) { - error.NoTerminal => { - ui.showToast("No terminal to paste into", now); - return; - }, - error.NoShell => { - ui.showToast("Shell not available", now); - return; - }, - else => return err, - }; - - ui.showToast("Pasted clipboard", now); + try runtime.run(); } diff --git a/src/shell.zig b/src/shell.zig index f721df5..cdcdc89 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -229,12 +229,14 @@ pub const Shell = struct { setDefaultEnv("TERM_PROGRAM", DEFAULT_TERM_PROGRAM); // Change to specified directory or home directory before spawning shell. - // Errors are intentionally ignored: we're in a forked child process where - // logging is impractical, and chdir failure is non-fatal (shell starts in - // the parent's cwd instead). Try working_dir first, fall back to HOME. + // Try working_dir first, fall back to HOME. const target_dir = working_dir orelse posix.getenv("HOME"); if (target_dir) |dir| { - posix.chdir(dir) catch {}; + posix.chdir(dir) catch { + // Errors are intentionally ignored: we're in a forked child process where + // logging is impractical, and chdir failure is non-fatal (shell starts in + // the parent's cwd instead). + }; } posix.dup2(pty_instance.slave, posix.STDIN_FILENO) catch std.c._exit(1);