diff --git a/CLAUDE.md b/CLAUDE.md index 5847349..648d21e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ Guidance for any code agent working on the Architect repo. Keep this file instru ## Coding Conventions - Favor self-documenting code; keep comments minimal and meaningful. - Default to ASCII unless the file already uses non-ASCII. +- Always handle errors explicitly: propagate, recover, or log; do not swallow errors with bare `catch {}` / `catch unreachable` unless proven impossible. - Run `zig fmt src/` (or `zig fmt` on touched Zig files) before wrapping up changes. - Avoid destructive git commands and do not revert user changes. diff --git a/README.md b/README.md index e093580..a766b93 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Architect Hero](docs/assets/architect-hero.png) -A Zig terminal multiplexer that displays 9 interactive terminal sessions in a 3×3 grid with smooth expand/collapse animations. Built on ghostty-vt for terminal emulation and SDL3 for rendering. +A Zig terminal multiplexer that displays a configurable grid of interactive terminal sessions (default 3×3) with smooth expand/collapse animations. Built on ghostty-vt for terminal emulation and SDL3 for rendering. > [!WARNING] > **This project is in early stages of development. Use at your own risk.** @@ -71,7 +71,7 @@ cp -r $(brew --prefix)/Cellar/architect/*/Architect.app /Applications/ The formula will: - Build from source using Zig - Install all required dependencies (SDL3, SDL3_ttf) -- Create Architect.app with bundled fonts and icon +- Create Architect.app with the application icon (fonts are resolved from your system based on `config.toml`) - After copying to /Applications, launch from Spotlight or: `open -a Architect` ### Build from Source @@ -80,7 +80,7 @@ See [Setup](#setup) section below for building from source. ## Features -- **3×3 Terminal Grid**: Run 9 independent shell sessions simultaneously +- **Configurable Grid**: Run multiple independent shell sessions; defaults to 3×3 but rows/cols are configurable (1–12) in `config.toml` - **Smooth Animations**: Click any terminal to smoothly expand it to full screen - **Full-Window Scaling**: Each terminal is sized for the full window and scaled down in grid view - **Resizable Window**: Dynamically resize the window with automatic terminal and PTY resizing @@ -376,7 +376,7 @@ Architect integrates with AI coding assistants through a Unix domain socket prot - **Per-shell env**: Each spawned shell receives `ARCHITECT_SESSION_ID` (0‑based grid index) and `ARCHITECT_NOTIFY_SOCK` (socket path) so tools inside the terminal can send status. - **Protocol**: Send a single-line JSON object to the socket: - `{"session":0,"state":"start"}` clears the highlight and marks the session as running. - - `{"session":0,"state":"awaiting_approval"}` turns on a pulsing yellow border in the 3×3 grid (request). + - `{"session":0,"state":"awaiting_approval"}` turns on a pulsing yellow border in the grid (request). - `{"session":0,"state":"done"}` shows a solid green border in the grid (completion). **Example from inside a terminal session:** @@ -599,7 +599,7 @@ Download the latest release from the [releases page](https://github.com/forketyf ## Architecture ### Terminal Scaling -Each terminal session is initialized with full-window dimensions (calculated from font metrics). In grid view, these full-sized terminals are scaled down to 1/3 and rendered into grid cells, providing a "zoomed out" view of complete terminal sessions. +Each terminal session is initialized with full-window dimensions (calculated from font metrics). In grid view, these full-sized terminals are scaled down to the current grid cell size, providing a "zoomed out" view of complete terminal sessions regardless of the configured rows/cols. ### Animation System The application uses cubic ease-in-out interpolation to smoothly transition between grid and full-screen views over 300ms. Six view modes (Grid, Expanding, Full, Collapsing, PanningLeft, PanningRight) manage the animation state, including horizontal panning for terminal switching. @@ -613,7 +613,7 @@ The application uses cubic ease-in-out interpolation to smoothly transition betw ## Implementation Status ✅ **Fully Implemented**: -- 3×3 grid layout with 9 terminal sessions +- Configurable grid layout (defaults to 3×3) with per-cell terminal sessions - PTY management and shell spawning - Real-time terminal I/O - SDL3 window and event loop with resizable window support @@ -636,7 +636,7 @@ The application uses cubic ease-in-out interpolation to smoothly transition betw The following features are not yet fully implemented: - **Emoji coverage is macOS-only**: Apple Color Emoji fallback is used; other platforms may still show tofu or monochrome glyphs for emoji and complex ZWJ sequences. -- **Limited font distribution**: Only the bundled font family ships with the app today +- **Fonts must exist locally**: Architect relies on system-installed fonts; ensure your configured family is available on the host OS. - **Limited configurability**: Keybindings are hardcoded ## License diff --git a/docs/architecture.md b/docs/architecture.md index 6b200fd..823dfae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,17 +28,18 @@ Architect is a terminal multiplexer displaying 9 interactive sessions in a 3×3 ## Runtime Flow -**main.zig** owns application lifetime, window sizing, PTY/session startup, configuration persistence, and the frame loop. Each frame it: +**main.zig** owns application lifetime, window sizing, PTY/session startup, configuration persistence, and the frame loop. Each iteration it: -1. Polls SDL events and scales coordinates to render space. -2. Builds a lightweight `UiHost` snapshot and lets `UiRoot` handle events first. -3. Runs remaining app logic (terminal input, resizing, keyboard shortcuts). -4. Runs `xev` loop iteration for async process exit detection. -5. Processes output from all sessions and drains async notifications. +1. Computes a wait deadline (0ms when work is pending, ~16ms during inertia, up to 500ms when idle) and blocks on `SDL_WaitEventTimeout`. +2. SDL events include user events posted by a dedicated IO thread (xev) whenever a PTY has data or a child exits. +3. Builds a lightweight `UiHost` snapshot and lets `UiRoot` handle events first. +4. Runs remaining app logic (terminal input, resizing, keyboard shortcuts). +5. Processes PTY output only for sessions marked “ready” by the IO thread, then flushes queued stdin. 6. Updates UI components and drains `UiAction` queue. 7. Advances animation state if transitioning. -8. Calls `renderer.render` for the scene, then `ui.render` for overlays, then presents. -9. Sleeps based on idle/active frame targets (~16ms active, ~50ms idle). +8. Calls `renderer.render` for the scene, then `ui.render` for overlays, then presents—only when something is dirty/animating. + +The IO-facing work is isolated on a dedicated xev thread: it watches all PTY masters for readability and process exits, then posts SDL user events that wake the main loop. Access to the xev loop is serialized with a mutex when (re)registering watchers during spawns or restarts. **Terminal resizing** - `applyTerminalResize` updates the PTY size first, then resizes the `ghostty-vt` terminal. diff --git a/src/c.zig b/src/c.zig index 4402a3b..20b8a2f 100644 --- a/src/c.zig +++ b/src/c.zig @@ -56,6 +56,9 @@ pub const SDL_FillSurfaceRect = c_import.SDL_FillSurfaceRect; pub const SDL_BlitSurface = c_import.SDL_BlitSurface; pub const SDL_GetError = c_import.SDL_GetError; pub const SDL_PollEvent = c_import.SDL_PollEvent; +pub const SDL_WaitEventTimeout = c_import.SDL_WaitEventTimeout; +pub const SDL_PushEvent = c_import.SDL_PushEvent; +pub const SDL_RegisterEvents = c_import.SDL_RegisterEvents; pub const SDL_Delay = c_import.SDL_Delay; pub const SDL_StartTextInput = c_import.SDL_StartTextInput; pub const SDL_StopTextInput = c_import.SDL_StopTextInput; @@ -79,6 +82,7 @@ pub const SDL_EVENT_MOUSE_MOTION = c_import.SDL_EVENT_MOUSE_MOTION; pub const SDL_EVENT_MOUSE_WHEEL = c_import.SDL_EVENT_MOUSE_WHEEL; pub const SDL_EVENT_WINDOW_RESIZED = c_import.SDL_EVENT_WINDOW_RESIZED; pub const SDL_EVENT_WINDOW_MOVED = c_import.SDL_EVENT_WINDOW_MOVED; +pub const SDL_EVENT_USER = c_import.SDL_EVENT_USER; pub const SDL_EVENT_DROP_BEGIN = c_import.SDL_EVENT_DROP_BEGIN; pub const SDL_EVENT_DROP_FILE = c_import.SDL_EVENT_DROP_FILE; pub const SDL_EVENT_DROP_TEXT = c_import.SDL_EVENT_DROP_TEXT; @@ -96,6 +100,9 @@ pub const SDL_SCANCODE_END = c_import.SDL_SCANCODE_END; pub const SDL_KMOD_MODE = c_import.SDL_KMOD_MODE; pub const SDL_SCANCODE_AC_HOME = c_import.SDL_SCANCODE_AC_HOME; pub const SDL_SCANCODE_AC_END = c_import.SDL_SCANCODE_AC_END; +pub const SDL_DisplayID = c_import.SDL_DisplayID; +pub const SDL_GetPrimaryDisplay = c_import.SDL_GetPrimaryDisplay; +pub const SDL_GetDisplayBounds = c_import.SDL_GetDisplayBounds; pub const SDL_SetTextureScaleMode = c_import.SDL_SetTextureScaleMode; pub const SDL_SCALEMODE_LINEAR = c_import.SDL_SCALEMODE_LINEAR; diff --git a/src/main.zig b/src/main.zig index 589d139..91c178a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -34,9 +34,6 @@ 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 SessionStatus = app_state.SessionStatus; const ViewMode = app_state.ViewMode; const Rect = app_state.Rect; @@ -87,6 +84,108 @@ fn handleQuitRequest( return true; } +const IoLoopContext = struct { + loop: *xev.Loop, + queue_mutex: *std.Thread.Mutex, + pending_registrations: *std.ArrayListUnmanaged(*SessionState), + stop: *std.atomic.Value(bool), + async: *xev.Async, +}; + +fn ioLoopThread(ctx: *IoLoopContext) void { + while (!ctx.stop.load(.acquire)) { + // Drain registration requests sent from the main thread. + ctx.queue_mutex.lock(); + const count = ctx.pending_registrations.items.len; + if (count > 0) { + var i: usize = 0; + while (i < count) : (i += 1) { + const session = ctx.pending_registrations.items[i]; + session.registerIoWatchers(ctx.loop) catch |err| { + log.err("failed to register IO watchers for session {d}: {}", .{ session.id, err }); + }; + } + ctx.pending_registrations.clearRetainingCapacity(); + } + ctx.queue_mutex.unlock(); + + // Block until something is ready (async wake or IO watcher). + const run_result = ctx.loop.run(.once); + run_result catch |err| { + log.err("IO loop run error: {}", .{err}); + }; + } +} + +fn ioAsyncCallback( + stop_flag: ?*std.atomic.Value(bool), + _: *xev.Loop, + _: *xev.Completion, + result: xev.Async.WaitError!void, +) xev.CallbackAction { + result catch |err| { + log.err("async wake error: {}", .{err}); + }; + if (stop_flag) |flag| { + if (flag.load(.acquire)) return .disarm; + } + return .rearm; +} + +fn spawnSessionWithLoop( + session: *SessionState, + loop: *xev.Loop, + queue_mutex: *std.Thread.Mutex, + queue: *std.ArrayListUnmanaged(*SessionState), + async: *xev.Async, +) !void { + try session.ensureSpawnedWithLoop(loop); + queue_mutex.lock(); + defer queue_mutex.unlock(); + try queue.append(std.heap.page_allocator, session); + async.notify() catch |err| { + log.err("failed to wake IO thread after spawn: {}", .{err}); + }; +} + +fn spawnSessionWithDirAndLoop( + session: *SessionState, + queue_mutex: *std.Thread.Mutex, + queue: *std.ArrayListUnmanaged(*SessionState), + async: *xev.Async, + working_dir: ?[:0]const u8, +) !void { + try session.ensureSpawnedWithDir(working_dir); + queue_mutex.lock(); + defer queue_mutex.unlock(); + try queue.append(std.heap.page_allocator, session); + async.notify() catch |err| { + log.err("failed to wake IO thread after spawn with dir: {}", .{err}); + }; +} + +fn clampWindowPositionToPrimary( + desired: platform.WindowPosition, + window_w: c_int, + window_h: c_int, +) ?platform.WindowPosition { + const primary = c.SDL_GetPrimaryDisplay(); + if (primary == 0) return desired; + var bounds: c.SDL_Rect = undefined; + if (!c.SDL_GetDisplayBounds(primary, &bounds)) return desired; + + const margin: c_int = 32; + const min_x = bounds.x - window_w + margin; + const max_x = bounds.x + bounds.w - margin; + const min_y = bounds.y - window_h + margin; + const max_y = bounds.y + bounds.h - margin; + + if (desired.x < min_x or desired.x > max_x or desired.y < min_y or desired.y > max_y) { + return null; + } + return desired; +} + pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -141,16 +240,43 @@ pub fn main() !void { const theme = colors_mod.Theme.fromConfig(config.theme); + const session_event_type_count: c_int = 1; + const session_event_type_base: u32 = c.SDL_RegisterEvents(session_event_type_count); + const session_event_type: u32 = if (session_event_type_base == std.math.maxInt(u32)) blk: { + log.err("Failed to register SDL user events; falling back to SDL_EVENT_USER", .{}); + break :blk c.SDL_EVENT_USER; + } else session_event_type_base; + session_state.setSessionEventType(session_event_type); + + var io_loop = try xev.Loop.init(.{}); + defer io_loop.deinit(); + var io_queue_mutex = std.Thread.Mutex{}; + var io_pending_registrations = std.ArrayListUnmanaged(*SessionState){}; + var io_stop = std.atomic.Value(bool).init(false); + + var io_async = try xev.Async.init(); + defer io_async.deinit(); + var io_async_completion: xev.Completion = .{}; + { + io_queue_mutex.lock(); + defer io_queue_mutex.unlock(); + io_async.wait(&io_loop, &io_async_completion, std.atomic.Value(bool), &io_stop, ioAsyncCallback); + } + const grid_rows: usize = @intCast(config.grid.rows); const grid_cols: usize = @intCast(config.grid.cols); const grid_count: usize = grid_rows * grid_cols; 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) + const persisted_pos = if (persistence.window.x >= 0 and persistence.window.y >= 0) platform.WindowPosition{ .x = persistence.window.x, .y = persistence.window.y } else null; + const window_pos = if (persisted_pos) |pos| + clampWindowPositionToPrimary(pos, persistence.window.width, persistence.window.height) + else + null; var sdl = try platform.init( "ARCHITECT", @@ -255,9 +381,6 @@ pub fn main() !void { 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}); @@ -265,7 +388,7 @@ pub fn main() !void { init_count += 1; } - try sessions[0].ensureSpawnedWithLoop(&loop); + try spawnSessionWithLoop(&sessions[0], &io_loop, &io_queue_mutex, &io_pending_registrations, &io_async); defer { for (sessions) |*session| { @@ -274,6 +397,22 @@ pub fn main() !void { allocator.free(sessions); } + var io_ctx = IoLoopContext{ + .loop = &io_loop, + .queue_mutex = &io_queue_mutex, + .pending_registrations = &io_pending_registrations, + .stop = &io_stop, + .async = &io_async, + }; + const io_thread = try std.Thread.spawn(.{}, ioLoopThread, .{&io_ctx}); + defer { + io_stop.store(true, .release); + io_async.notify() catch |err| { + log.err("failed to wake IO thread during shutdown: {}", .{err}); + }; + io_thread.join(); + } + var running = true; var anim_state = AnimationState{ @@ -302,12 +441,31 @@ pub fn main() !void { const global_shortcuts_component = try ui_mod.global_shortcuts.GlobalShortcutsComponent.create(allocator); try ui.register(global_shortcuts_component); - // Main loop: handle SDL input, feed PTY output into terminals, apply async - // notifications, drive animations, and render at ~60 FPS. + // Main loop: event-driven; blocks on SDL, woken by IO thread user events or UI needs. var previous_frame_ns: i128 = undefined; var first_frame: bool = true; var last_render_ns: i128 = 0; + var force_next_frame = true; + var scroll_inertia_active_prev = false; while (running) { + var wait_ms: u32 = 500; + if (force_next_frame) { + wait_ms = 0; + } else if (scroll_inertia_active_prev) { + wait_ms = 16; + } + + var processed_event = false; + var first_event_opt: ?c.SDL_Event = null; + var first_event: c.SDL_Event = undefined; + if (c.SDL_PollEvent(&first_event)) { + processed_event = true; + first_event_opt = first_event; + } else if (c.SDL_WaitEventTimeout(&first_event, @as(i32, @intCast(wait_ms)))) { + processed_event = true; + first_event_opt = first_event; + } + const frame_start_ns: i128 = std.time.nanoTimestamp(); const now = std.time.milliTimestamp(); var delta_time_s: f32 = 0.0; @@ -318,9 +476,15 @@ pub fn main() !void { } previous_frame_ns = frame_start_ns; + var pending_event = first_event_opt; var event: c.SDL_Event = undefined; - var processed_event = false; - while (c.SDL_PollEvent(&event)) { + while (true) { + if (pending_event) |ev| { + event = ev; + pending_event = null; + } else if (!c.SDL_PollEvent(&event)) { + break; + } processed_event = true; var scaled_event = scaleEventToRender(&event, scale_x, scale_y); const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count); @@ -449,8 +613,8 @@ pub fn main() !void { grid_rows, ) orelse continue; - var session = &sessions[hovered_session]; - try session.ensureSpawnedWithLoop(&loop); + const session = &sessions[hovered_session]; + try spawnSessionWithLoop(session, &io_loop, &io_queue_mutex, &io_pending_registrations, &io_async); const escaped = shellQuotePath(allocator, drop_path) catch |err| { std.debug.print("Failed to escape dropped path: {}\n", .{err}); @@ -547,7 +711,7 @@ pub fn main() !void { defer if (cwd_buf) |buf| allocator.free(buf); - try sessions[next_free_idx].ensureSpawnedWithDir(cwd_z, &loop); + try spawnSessionWithDirAndLoop(&sessions[next_free_idx], &io_queue_mutex, &io_pending_registrations, &io_async, cwd_z); sessions[next_free_idx].status = .running; sessions[next_free_idx].attention = false; @@ -576,7 +740,7 @@ pub fn main() !void { }; ui.showHotkey(arrow, now); } - try navigateGrid(&anim_state, sessions, direction, now, true, false, grid_cols, grid_rows); + try navigateGrid(&anim_state, sessions, direction, now, true, false, &io_loop, &io_queue_mutex, &io_pending_registrations, &io_async, grid_cols, grid_rows); const new_session = anim_state.focused_session; sessions[new_session].dirty = true; std.debug.print("Grid nav to session {d} (with wrapping)\n", .{new_session}); @@ -590,7 +754,7 @@ pub fn main() !void { }; ui.showHotkey(arrow, now); } - try navigateGrid(&anim_state, sessions, direction, now, true, animations_enabled, grid_cols, grid_rows); + try navigateGrid(&anim_state, sessions, direction, now, true, animations_enabled, &io_loop, &io_queue_mutex, &io_pending_registrations, &io_async, grid_cols, grid_rows); const buf_size = gridNotificationBufferSize(grid_cols, grid_rows); const notification_buf = try allocator.alloc(u8, buf_size); @@ -607,7 +771,7 @@ pub fn main() !void { } else if (key == c.SDLK_RETURN and (mod & c.SDL_KMOD_GUI) != 0 and anim_state.mode == .Grid) { if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘↵", now); const clicked_session = anim_state.focused_session; - try sessions[clicked_session].ensureSpawned(); + try spawnSessionWithLoop(&sessions[clicked_session], &io_loop, &io_queue_mutex, &io_pending_registrations, &io_async); sessions[clicked_session].status = .running; sessions[clicked_session].attention = false; @@ -648,7 +812,9 @@ pub fn main() !void { 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 {}; + _ = focused.shell.?.write(&esc_byte) catch |err| { + log.err("failed to send escape to session {d}: {}", .{ focused.id, err }); + }; } std.debug.print("Escape released, sent to terminal\n", .{}); } @@ -670,7 +836,7 @@ pub fn main() !void { .h = cell_height_pixels, }; - try sessions[clicked_session].ensureSpawned(); + try spawnSessionWithLoop(&sessions[clicked_session], &io_loop, &io_queue_mutex, &io_pending_registrations, &io_async); sessions[clicked_session].status = .running; sessions[clicked_session].attention = false; @@ -842,17 +1008,38 @@ pub fn main() !void { } } }, - else => {}, + else => { + if (scaled_event.type == session_event_type) { + const code: session_state.SessionEventCode = @enumFromInt(scaled_event.user.code); + const raw_ptr = @intFromPtr(scaled_event.user.data1); + if (raw_ptr > 0) { + const session_idx: usize = raw_ptr - 1; + if (session_idx < sessions.len) { + var session = &sessions[session_idx]; + switch (code) { + .pty_read_ready => { + session.needs_output_drain = true; + }, + .process_exited => { + session.dead = true; + session.dirty = true; + }, + } + } + } + continue; + } + }, } } - try loop.run(.no_wait); - var any_session_dirty = false; var has_scroll_inertia = false; for (sessions) |*session| { - session.checkAlive(); - try session.processOutput(); + if (session.needs_output_drain) { + session.needs_output_drain = false; + try session.processOutput(); + } try session.flushPendingWrites(); session.updateCwd(now); updateScrollInertia(session, delta_time_s); @@ -898,7 +1085,13 @@ pub fn main() !void { while (ui.popAction()) |action| switch (action) { .RestartSession => |idx| { if (idx < sessions.len) { - try sessions[idx].restart(); + try sessions[idx].restart(&io_loop); + io_queue_mutex.lock(); + defer io_queue_mutex.unlock(); + try io_pending_registrations.append(std.heap.page_allocator, &sessions[idx]); + io_async.notify() catch |err| { + log.err("failed to wake IO thread after restart: {}", .{err}); + }; std.debug.print("UI requested restart: {d}\n", .{idx}); } }, @@ -995,8 +1188,7 @@ pub fn main() !void { 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; + const should_render = animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications; if (should_render) { try renderer_mod.render(renderer, sessions, cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, ui_scale, font_paths.regular, &theme, config.grid.font_scale); @@ -1004,20 +1196,8 @@ pub fn main() !void { _ = c.SDL_RenderPresent(renderer); last_render_ns = std.time.nanoTimestamp(); } - - const is_idle = !animating and !any_session_dirty and !ui_needs_frame and !processed_event and !had_notifications and !has_scroll_inertia; - // 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); - } - } + scroll_inertia_active_prev = has_scroll_inertia; + force_next_frame = should_render or has_scroll_inertia; } } @@ -1090,6 +1270,10 @@ fn navigateGrid( now: i64, enable_wrapping: bool, show_animation: bool, + io_loop: *xev.Loop, + io_queue_mutex: *std.Thread.Mutex, + io_queue: *std.ArrayListUnmanaged(*SessionState), + io_async: *xev.Async, grid_cols: usize, grid_rows: usize, ) !void { @@ -1150,9 +1334,9 @@ fn navigateGrid( 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].ensureSpawned(); + try spawnSessionWithLoop(&sessions[new_session], io_loop, io_queue_mutex, io_queue, io_async); } else if (show_animation) { - try sessions[new_session].ensureSpawned(); + try spawnSessionWithLoop(&sessions[new_session], io_loop, io_queue_mutex, io_queue, io_async); } sessions[anim_state.focused_session].clearSelection(); sessions[new_session].clearSelection(); @@ -1492,7 +1676,9 @@ fn startSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { session.selection_pending = false; terminal.screens.active.clearSelection(); - terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch {}; + terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { + log.err("failed to start selection drag for session {d}: {}", .{ session.id, err }); + }; session.dirty = true; } @@ -1500,7 +1686,9 @@ fn updateSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void { if (!session.selection_dragging) return; const anchor = session.selection_anchor orelse return; const terminal = session.terminal orelse return; - terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch {}; + terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| { + log.err("failed to update selection drag for session {d}: {}", .{ session.id, err }); + }; session.dirty = true; } @@ -1894,7 +2082,9 @@ fn clearTerminal(session: *SessionState) void { session.dirty = true; // Trigger shell redraw like Ghostty (FF) so the prompt is repainted at top. - session.sendInput(&[_]u8{0x0C}) catch {}; + session.sendInput(&[_]u8{0x0C}) catch |err| { + log.err("failed to send clear-screen to session {d}: {}", .{ session.id, err }); + }; } fn copySelectionToClipboard( diff --git a/src/session/notify.zig b/src/session/notify.zig index e449544..8cd2ae7 100644 --- a/src/session/notify.zig +++ b/src/session/notify.zig @@ -3,6 +3,8 @@ const posix = std.posix; const app_state = @import("../app/app_state.zig"); const atomic = std.atomic; +const log = std.log.scoped(.notify); + pub const Notification = struct { session: usize, state: app_state.SessionStatus, @@ -58,7 +60,7 @@ pub fn startNotifyThread( ) StartNotifyThreadError!std.Thread { _ = std.posix.unlink(socket_path) catch |err| switch (err) { error.FileNotFound => {}, - else => {}, + else => log.warn("failed to unlink notify socket: {}", .{err}), }; const handler = struct { @@ -104,15 +106,20 @@ pub fn startNotifyThread( try posix.bind(fd, &addr.any, addr.getOsSockLen()); try posix.listen(fd, 16); const sock_path = std.mem.sliceTo(ctx.socket_path, 0); - _ = std.posix.fchmodat(posix.AT.FDCWD, sock_path, 0o600, 0) catch {}; + _ = std.posix.fchmodat(posix.AT.FDCWD, sock_path, 0o600, 0) catch |err| { + log.warn("failed to chmod notify socket: {}", .{err}); + }; // Make accept non-blocking so the loop can observe stop requests. - const flags = posix.fcntl(fd, posix.F.GETFL, 0) catch null; - if (flags) |f| { - var o_flags: posix.O = @bitCast(@as(u32, @intCast(f))); - o_flags.NONBLOCK = true; - _ = posix.fcntl(fd, posix.F.SETFL, @as(u32, @bitCast(o_flags))) catch {}; - } + const flags = posix.fcntl(fd, posix.F.GETFL, 0) catch |err| { + log.warn("failed to get flags for notify socket: {}", .{err}); + return; + }; + var o_flags: posix.O = @bitCast(@as(u32, @intCast(flags))); + o_flags.NONBLOCK = true; + _ = posix.fcntl(fd, posix.F.SETFL, @as(u32, @bitCast(o_flags))) catch |err| { + log.warn("failed to set NONBLOCK on notify socket: {}", .{err}); + }; while (!ctx.stop.load(.seq_cst)) { const conn_fd = posix.accept(fd, null, null, 0) catch |err| switch (err) { @@ -124,12 +131,15 @@ pub fn startNotifyThread( }; defer posix.close(conn_fd); - const conn_flags = posix.fcntl(conn_fd, posix.F.GETFL, 0) catch null; - if (conn_flags) |f| { - var o_flags: posix.O = @bitCast(@as(u32, @intCast(f))); - o_flags.NONBLOCK = true; - _ = posix.fcntl(conn_fd, posix.F.SETFL, @as(u32, @bitCast(o_flags))) catch {}; - } + const conn_flags = posix.fcntl(conn_fd, posix.F.GETFL, 0) catch |err| { + log.warn("failed to get flags for notify conn: {}", .{err}); + return; + }; + var conn_o_flags: posix.O = @bitCast(@as(u32, @intCast(conn_flags))); + conn_o_flags.NONBLOCK = true; + _ = posix.fcntl(conn_fd, posix.F.SETFL, @as(u32, @bitCast(conn_o_flags))) catch |err| { + log.warn("failed to set NONBLOCK on notify conn: {}", .{err}); + }; var buffer = std.ArrayList(u8){}; defer buffer.deinit(ctx.allocator); @@ -148,7 +158,9 @@ pub fn startNotifyThread( if (buffer.items.len == 0) continue; if (parseNotification(buffer.items)) |note| { - ctx.queue.push(ctx.allocator, note) catch {}; + ctx.queue.push(ctx.allocator, note) catch |err| { + log.err("failed to enqueue notification: {}", .{err}); + }; } } } diff --git a/src/session/state.zig b/src/session/state.zig index 161b6fb..f93cf12 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -23,8 +23,22 @@ const log = std.log.scoped(.session_state); extern "c" fn tcgetpgrp(fd: posix.fd_t) posix.pid_t; extern "c" fn ptsname(fd: posix.fd_t) ?[*:0]const u8; +const XevStream = xev.Stream(xev); + const PENDING_WRITE_SHRINK_THRESHOLD: usize = 64 * 1024; +/// SDL user-event type used by the IO thread to wake the main loop. +var session_event_type: u32 = 0; + +pub const SessionEventCode = enum(c_int) { + pty_read_ready = 1, + process_exited = 2, +}; + +pub fn setSessionEventType(event_type: u32) void { + session_event_type = event_type; +} + pub const SessionState = struct { id: usize, shell: ?shell_mod.Shell, @@ -35,6 +49,7 @@ pub const SessionState = struct { attention: bool = false, is_scrolled: bool = false, dirty: bool = true, + needs_output_drain: bool = false, cache_texture: ?*c.SDL_Texture = null, cache_w: c_int = 0, cache_h: c_int = 0, @@ -73,6 +88,9 @@ pub const SessionState = struct { hovered_link_start: ?ghostty_vt.Pin = null, hovered_link_end: ?ghostty_vt.Pin = null, pending_write: std.ArrayListUnmanaged(u8) = .empty, + /// Read watcher for PTY readiness notifications (IO thread). + read_stream: ?XevStream = null, + read_completion: xev.Completion = .{}, /// Process watcher for event-driven exit detection. process_watcher: ?xev.Process = null, /// Completion structure for process wait callback. @@ -123,14 +141,15 @@ pub const SessionState = struct { } pub fn ensureSpawned(self: *SessionState) InitError!void { - return self.ensureSpawnedWithDir(null, null); + return self.ensureSpawnedWithDir(null); } pub fn ensureSpawnedWithLoop(self: *SessionState, loop: *xev.Loop) InitError!void { - return self.ensureSpawnedWithDir(null, loop); + _ = loop; // Loop registration happens on the IO thread only. + return self.ensureSpawnedWithDir(null); } - pub fn ensureSpawnedWithDir(self: *SessionState, working_dir: ?[:0]const u8, loop_opt: ?*xev.Loop) InitError!void { + pub fn ensureSpawnedWithDir(self: *SessionState, working_dir: ?[:0]const u8) InitError!void { if (self.spawned) return; const shell = try shell_mod.Shell.spawn( @@ -165,24 +184,11 @@ pub const SessionState = struct { self.cwd_dirty = true; self.dirty = true; - if (loop_opt) |loop| { - var process = try xev.Process.init(shell.child_pid); - errdefer process.deinit(); - - process.wait( - loop, - &self.process_completion, - SessionState, - self, - processExitCallback, - ); - - self.process_watcher = process; - } - log.debug("spawned session {d}", .{self.id}); - self.processOutput() catch {}; + self.processOutput() catch |err| { + log.err("failed to process initial output for session {d}: {}", .{ self.id, err }); + }; } pub fn deinit(self: *SessionState, allocator: std.mem.Allocator) void { @@ -204,9 +210,7 @@ pub const SessionState = struct { if (self.cwd_path) |path| { allocator.free(path); } - if (self.process_watcher) |*watcher| { - watcher.deinit(); - } + self.teardownIoWatchers(); if (self.spawned) { if (self.stream) |*stream| { stream.deinit(); @@ -240,19 +244,87 @@ pub const SessionState = struct { _: *xev.Completion, r: xev.Process.WaitError!u32, ) xev.CallbackAction { - const self = self_opt orelse return .disarm; const exit_code = r catch |err| { - log.err("process wait error for session {d}: {}", .{ self.id, err }); + if (self_opt) |self| { + log.err("process wait error for session {d}: {}", .{ self.id, err }); + } else { + log.err("process wait error for unknown session: {}", .{err}); + } return .disarm; }; - self.dead = true; - self.dirty = true; - log.info("session {d} process exited with code {d}", .{ self.id, exit_code }); - + if (self_opt) |self| { + log.info("session {d} process exited with code {d}", .{ self.id, exit_code }); + pushSessionEvent(self.id, .process_exited); + } else { + log.warn("received process exit callback with no session (code {d})", .{exit_code}); + } return .disarm; } + fn readReadyCallback( + self_opt: ?*SessionState, + _: *xev.Loop, + _: *xev.Completion, + _: XevStream, + r: xev.PollError!xev.PollEvent, + ) xev.CallbackAction { + _ = r catch |err| { + if (self_opt) |self| { + log.err("poll error for session {d}: {}", .{ self.id, err }); + } else { + log.err("poll error for unknown session: {}", .{err}); + } + return .rearm; + }; + + if (self_opt) |self| { + pushSessionEvent(self.id, .pty_read_ready); + } else { + log.warn("received poll event with no session", .{}); + } + return .rearm; + } + + fn pushSessionEvent(session_id: usize, code: SessionEventCode) void { + if (session_event_type == 0) return; + var ev: c.SDL_Event = undefined; + ev.type = session_event_type; + ev.user.code = @intCast(@intFromEnum(code)); + ev.user.data1 = @ptrFromInt(session_id + 1); + if (!c.SDL_PushEvent(&ev)) { + log.err( + "failed to push SDL user event for session {d} (code {s})", + .{ session_id, @tagName(code) }, + ); + } + } + + pub fn registerIoWatchers(self: *SessionState, loop: *xev.Loop) !void { + self.teardownIoWatchers(); + + var stream = XevStream.initFd(self.shell.?.pty.master); + stream.poll(loop, &self.read_completion, xev.PollEvent.read, SessionState, self, readReadyCallback); + self.read_stream = stream; + + var process = try xev.Process.init(self.shell.?.child_pid); + process.wait(loop, &self.process_completion, SessionState, self, processExitCallback); + self.process_watcher = process; + } + + fn teardownIoWatchers(self: *SessionState) void { + if (self.process_watcher) |*watcher| { + watcher.deinit(); + self.process_watcher = null; + } + if (self.read_stream) |*s| { + s.deinit(); + self.read_stream = null; + } + self.read_completion = .{}; + self.process_completion = .{}; + } + pub fn checkAlive(self: *SessionState) void { if (!self.spawned or self.dead) return; @@ -267,7 +339,7 @@ pub const SessionState = struct { } } - pub fn restart(self: *SessionState) InitError!void { + pub fn restart(self: *SessionState, loop: *xev.Loop) InitError!void { if (self.spawned and !self.dead) return; self.clearSelection(); @@ -296,7 +368,7 @@ pub const SessionState = struct { self.scroll_velocity = 0.0; self.scroll_remainder = 0.0; self.last_scroll_time = 0; - try self.ensureSpawned(); + try self.ensureSpawnedWithLoop(loop); } pub fn clearSelection(self: *SessionState) void { diff --git a/src/shell.zig b/src/shell.zig index 527114c..9932d01 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -66,9 +66,13 @@ pub const Shell = struct { // Change to specified directory or home directory before spawning shell if (working_dir) |dir| { - posix.chdir(dir) catch {}; + posix.chdir(dir) catch |err| { + log.err("failed to chdir to requested dir: {}", .{err}); + }; } else if (posix.getenv("HOME")) |home| { - posix.chdir(home) catch {}; + posix.chdir(home) catch |err| { + log.err("failed to chdir to HOME: {}", .{err}); + }; } posix.dup2(pty_instance.slave, posix.STDIN_FILENO) catch std.c._exit(1); diff --git a/src/ui/components/escape_hold.zig b/src/ui/components/escape_hold.zig index 8d1fedc..a13604b 100644 --- a/src/ui/components/escape_hold.zig +++ b/src/ui/components/escape_hold.zig @@ -75,7 +75,9 @@ pub const EscapeHoldComponent = struct { if (!self.gesture.active) return; if (self.gesture.isComplete(host.now_ms) and !self.gesture.consumed) { self.gesture.consumed = true; - actions.append(.RequestCollapseFocused) catch {}; + actions.append(.RequestCollapseFocused) catch |err| { + std.debug.print("escape_hold: failed to enqueue collapse action: {}\n", .{err}); + }; } if (self.gesture.consumed) { diff --git a/src/ui/components/global_shortcuts.zig b/src/ui/components/global_shortcuts.zig index a17459f..fd935fc 100644 --- a/src/ui/components/global_shortcuts.zig +++ b/src/ui/components/global_shortcuts.zig @@ -34,7 +34,9 @@ pub const GlobalShortcutsComponent = struct { const has_blocking_mod = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT)) != 0; if (key == c.SDLK_COMMA and has_gui and !has_blocking_mod) { - actions.append(.OpenConfig) catch {}; + actions.append(.OpenConfig) catch |err| { + std.debug.print("global_shortcuts: failed to enqueue open-config action: {}\n", .{err}); + }; return true; } diff --git a/src/ui/components/hotkey_indicator.zig b/src/ui/components/hotkey_indicator.zig index b9bd11b..9adf0a4 100644 --- a/src/ui/components/hotkey_indicator.zig +++ b/src/ui/components/hotkey_indicator.zig @@ -187,7 +187,9 @@ pub const HotkeyIndicatorComponent = struct { const end = @min(idx + seq_len, label_slice.len); const cp_slice = label_slice[idx..end]; const codepoint = std.unicode.utf8Decode(cp_slice) catch 0xFFFD; - self.font.renderGlyph(codepoint, x, y, self.font.cell_width, self.font.cell_height, text_color) catch {}; + self.font.renderGlyph(codepoint, x, y, self.font.cell_width, self.font.cell_height, text_color) catch |err| { + std.debug.print("hotkey_indicator: renderGlyph failed: {}\n", .{err}); + }; x += self.font.cell_width; idx = end; } diff --git a/src/ui/components/quit_confirm.zig b/src/ui/components/quit_confirm.zig index dd91c93..b3b7213 100644 --- a/src/ui/components/quit_confirm.zig +++ b/src/ui/components/quit_confirm.zig @@ -106,7 +106,9 @@ pub const QuitConfirmComponent = struct { const mod = event.key.mod; const is_confirm = key == c.SDLK_RETURN or key == c.SDLK_RETURN2 or key == c.SDLK_KP_ENTER or (key == c.SDLK_Q and (mod & c.SDL_KMOD_GUI) != 0); if (is_confirm) { - actions.append(.ConfirmQuit) catch {}; + actions.append(.ConfirmQuit) catch |err| { + std.debug.print("quit_confirm: failed to enqueue confirm quit: {}\n", .{err}); + }; self.visible = false; self.escape_pressed = false; return true; @@ -125,7 +127,9 @@ pub const QuitConfirmComponent = struct { const modal = self.modalRect(host); const buttons = self.buttonRects(modal, host.ui_scale); if (geom.containsPoint(buttons.quit, mouse_x, mouse_y)) { - actions.append(.ConfirmQuit) catch {}; + actions.append(.ConfirmQuit) catch |err| { + std.debug.print("quit_confirm: failed to enqueue confirm quit: {}\n", .{err}); + }; self.visible = false; return true; } diff --git a/src/ui/components/restart_buttons.zig b/src/ui/components/restart_buttons.zig index 4b24cb5..7314f38 100644 --- a/src/ui/components/restart_buttons.zig +++ b/src/ui/components/restart_buttons.zig @@ -72,7 +72,9 @@ pub const RestartButtonsComponent = struct { const inside = geom.containsPoint(button_rect, mouse_x, mouse_y); if (!inside) return false; - actions.append(.{ .RestartSession = clicked_session }) catch {}; + actions.append(.{ .RestartSession = clicked_session }) catch |err| { + std.debug.print("restart_buttons: failed to enqueue restart for session {d}: {}\n", .{ clicked_session, err }); + }; return true; }