diff --git a/.claude/skills/reload-architect/SKILL.md b/.claude/skills/reload-architect/SKILL.md new file mode 100644 index 00000000..0763a7a6 --- /dev/null +++ b/.claude/skills/reload-architect/SKILL.md @@ -0,0 +1,42 @@ +--- +name: reload-architect +description: Rebuild the Architect app from the current worktree (committed + uncommitted local changes) and relaunch it, so the running app reflects in-progress code. Use when the user wants to see/test their Architect changes in the actual app — e.g. "reload architect", "rebuild and restart architect", "run my changes", "relaunch with my edits", "I closed and reopened but don't see my changes". +--- + +# Reload Architect + +Goal: get the running Architect to be the build of **this worktree's current code** (including uncommitted changes). Editing source and relaunching the *installed* app does nothing — the `.app` must be rebuilt. This skill does build → quit → relaunch in one step via `scripts/dev-reload.sh`. + +## Steps + +1. Find the repo root and confirm we're in the Architect repo: + ```bash + ROOT="$(git rev-parse --show-toplevel)" && test -f "$ROOT/scripts/dev-reload.sh" && echo "$ROOT" + ``` + If that fails, tell the user to run the skill from inside the Architect repo (or a worktree of it) and stop. + +2. Run the reload from the repo root: + ```bash + bash "$ROOT/scripts/dev-reload.sh" + ``` + This builds (debug), packages the `.app`, quits the running Architect, swaps the fresh build into `/Applications`, and relaunches it. The relaunch is detached, so it completes on its own. + +3. Report the outcome: + - On success, tell the user Architect will close and reopen on the fresh build, and remind them what changed (branch + the local edits being tested). + - If the build fails (`zig build` error), surface the compiler error and offer to fix it. Do **not** leave them thinking it reloaded. + +## If the toolchain isn't reachable from your shell + +The build needs the Nix dev shell (`zig`). If `scripts/dev-reload.sh` exits with **"neither 'zig' nor 'nix' is on PATH"**, then this agent's shell can't reach the build toolchain (it lives in the user's login/direnv shell, not necessarily yours). In that case, do **not** keep retrying — tell the user to run it in their own session instead, where the toolchain is loaded: + +``` +!just reload +``` + +(or `!./scripts/dev-reload.sh`). The `!` prefix runs it in the user's interactive session, and the output comes back into the conversation. + +## Notes + +- Works in any Architect worktree — it resolves the repo root from the current directory, so it reloads whichever branch/worktree you're in. +- Safe to run while Architect is the frontmost app or in the background; the quit+relaunch is detached and survives even if it was launched from inside the Architect being replaced. +- This replaces `/Applications/Architect.app`, so Spotlight/Dock launches afterward also get the fresh build — no "which version am I running?" ambiguity. diff --git a/README.md b/README.md index 190ce3c6..f50b23f4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Status highlights** — agents glow when awaiting approval or done, so you never miss a prompt - **Agent session persistence** — when you quit Architect, any running Claude, Codex, or Gemini agents are gracefully terminated and their session IDs saved; on next launch the agents resume automatically where they left off - **Dynamic grid** — starts with a single terminal in full view; press ⌘N to add a terminal after the current one, and closing terminals compacts the grid forward -- **Grid view** — keep all agents visible simultaneously, expand any one to full screen +- **Grid view** — keep all agents visible simultaneously; single-click a pane to move focus to it (no shell is spawned), double-click to expand it to full screen - **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches; new worktrees are created outside the repo tree (configurable via `[worktree]` in `config.toml`) with automatic post-create initialization - **Recent folders** (⌘O) — quickly `cd` into recently visited directories with instant search filtering (start typing to narrow the list), substring highlighting, arrow key navigation, and ⌘1–⌘9 quick selection - **Diff review comments** — click diff lines in the ⌘D overlay to leave inline comments with multiline wrapping, then send them all to a running agent (or start one) with the "Send to agent" button diff --git a/docs/configuration.md b/docs/configuration.md index c99a41ca..a6d82e79 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -57,7 +57,7 @@ Note: Runtime window position and size are saved to `persistence.toml` and take ```toml [theme] -background = "#0E1116" # Terminal background color +background = "#262624" # Terminal background color foreground = "#CDD6E0" # Default text color selection = "#1B2230" # Selection highlight color accent = "#61AFEF" # Accent color (focused borders, UI elements) @@ -70,7 +70,7 @@ The configured theme colors are reused across terminal chrome and overlay surfac | Setting | Default | Description | |---------|---------|-------------| -| `background` | `#0E1116` | Dark gray background | +| `background` | `#262624` | Warm dark gray background | | `foreground` | `#CDD6E0` | Light gray text | | `selection` | `#1B2230` | Darker blue for selections | | `accent` | `#61AFEF` | Blue accent for focus indicators | diff --git a/docs/development.md b/docs/development.md index 9c3b3b6d..716cbb71 100644 --- a/docs/development.md +++ b/docs/development.md @@ -58,6 +58,18 @@ just run zig build run ``` +Reload the installed app with your in-progress changes: +```bash +just reload +# or +./scripts/dev-reload.sh +``` +This rebuilds from the current worktree (committed + uncommitted changes), quits the +running Architect, swaps the fresh build into `/Applications/Architect.app`, and +relaunches it. Use it to avoid restarting a stale binary after editing source — relaunching +the installed app alone never picks up source changes. The Claude Code skill +`/reload-architect` runs the same script. + ## Dependencies and Tooling - **ghostty-vt** is fetched as a pinned tarball via the Zig package manager (`build.zig.zon`). diff --git a/justfile b/justfile index 296a01bc..8a3fc750 100644 --- a/justfile +++ b/justfile @@ -23,6 +23,10 @@ run: run-release: zig build run -Doptimize=ReleaseFast +# Rebuild from the current worktree, then quit and relaunch the installed app. +reload: + ./scripts/dev-reload.sh + lint: #!/usr/bin/env bash set -euo pipefail diff --git a/scripts/dev-reload.sh b/scripts/dev-reload.sh new file mode 100755 index 00000000..ddd7cb7a --- /dev/null +++ b/scripts/dev-reload.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Rebuild Architect from the CURRENT worktree (committed + uncommitted changes) +# and relaunch it, so you never again restart a stale binary. +# +# Flow: build (debug) -> package an .app bundle -> quit the running Architect -> +# swap the fresh bundle into /Applications -> relaunch via `open`. +# +# The quit+relaunch runs in a detached subprocess (nohup/disown) so it survives +# even if this script is itself running inside the Architect being replaced. +set -euo pipefail + +ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" +if [[ -z "$ROOT" || ! -f "$ROOT/scripts/bundle-macos.sh" ]]; then + echo "error: run this from inside the Architect repo (no scripts/bundle-macos.sh found)" >&2 + exit 1 +fi +cd "$ROOT" + +# --- Ensure a working build toolchain + SDK --- +# Preferred path: a Nix dev shell provides zig, a linkable SDK, and SDL3. If +# zig is not on PATH but nix is, re-enter the flake dev shell and re-run there. +if ! command -v zig >/dev/null 2>&1; then + if command -v nix >/dev/null 2>&1; then + exec nix develop "$ROOT" -c "$0" "$@" + fi + echo "error: 'zig' is not on PATH and 'nix' is unavailable." >&2 + echo " Install zig (or symlink it onto PATH), or run from your Nix dev shell." >&2 + exit 1 +fi + +# Native (Homebrew) build setup. Each line only fills in a value that isn't +# already set, so this is a no-op inside a Nix dev shell that already provides them. + +# SDL3 / SDL3_ttf: build.zig reads *_INCLUDE_PATH and derives the lib dir (../lib). +if command -v brew >/dev/null 2>&1; then + : "${SDL3_INCLUDE_PATH:=$(brew --prefix sdl3 2>/dev/null)/include}" + : "${SDL3_TTF_INCLUDE_PATH:=$(brew --prefix sdl3_ttf 2>/dev/null)/include}" + export SDL3_INCLUDE_PATH SDL3_TTF_INCLUDE_PATH +fi + +# Zig 0.15.2 cannot link the macOS 26.x SDK family (ziglang/zig#31756). Redirect +# SDK discovery to the 15.4 SDK when present and DEVELOPER_DIR isn't already set. +legacy_sdk="/Library/Developer/CommandLineTools/SDKs/MacOSX15.4.sdk" +if [ -z "${DEVELOPER_DIR:-}" ] && [ -d "$legacy_sdk" ]; then + wr="$ROOT/.tmp/macos-sdk-workaround" + mkdir -p "$wr/bin" "$wr/developer/SDKs" "$wr/developer/usr/bin" + ln -sfn "$legacy_sdk" "$wr/developer/SDKs/MacOSX.sdk" + cat > "$wr/developer/usr/bin/xcrun" < Building Architect (Debug) from '$branch' + local changes..." + zig build +else + echo "==> Building Architect (ReleaseFast) from '$branch' + local changes..." + zig build -Doptimize=ReleaseFast +fi + +# Build-only mode: verify the build without touching the running app. +if [ -n "${DEV_RELOAD_BUILD_ONLY:-}" ]; then + echo "==> DEV_RELOAD_BUILD_ONLY set: built successfully, leaving the running app untouched." + exit 0 +fi + +echo "==> Packaging app bundle..." +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT +if [ "$build_debug" = true ]; then + ./scripts/bundle-macos.sh zig-out/bin/architect "$staging" zig-out/bin/architect-mcp --debug +else + ./scripts/bundle-macos.sh zig-out/bin/architect "$staging" zig-out/bin/architect-mcp +fi + +# Hand the swap+relaunch to a detached process. It outlives this shell, so the +# new Architect comes up even if quitting the old one tears down our terminal. +echo "==> Quitting Architect (waiting for agents to shut down + flush) and relaunching..." +old_pid="$(pgrep -x architect | head -1 || true)" +# Variables below are for the inner shell, not this one (intentional single quotes). +# shellcheck disable=SC2016 +nohup bash -c ' + set -e + staging="$1" + old_pid="$2" + # Quit gracefully, then block until the old process exits. This matters for + # agent resume: on quit, Architect Ctrl+Cs each running agent so Claude + # flushes its session to disk before exiting. Force-killing instead kills the + # agents mid-turn, so the next `claude --resume` reloads a rewound transcript. + # caffeinate -w waits on the process-exit event (no polling, no re-sending the + # quit). A watchdog force-kills ONLY if the teardown truly hangs (~5 min), + # which loses agent state. + osascript -e "quit app \"Architect\"" 2>/dev/null || true + if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then + ( sleep 300; kill -TERM "$old_pid" 2>/dev/null; sleep 2; kill -KILL "$old_pid" 2>/dev/null ) & + watchdog=$! + caffeinate -w "$old_pid" 2>/dev/null || true + kill "$watchdog" 2>/dev/null || true + wait "$watchdog" 2>/dev/null || true + fi + rm -rf "/Applications/Architect.app" + mv "$staging/Architect.app" "/Applications/Architect.app" + rmdir "$staging" 2>/dev/null || true + open "/Applications/Architect.app" +' _ "$staging" "$old_pid" >/dev/null 2>&1 & +disown + +# The detached job owns the staging dir now; do not let our EXIT trap delete it. +trap - EXIT + +echo "==> Done. Architect will close and reopen on the fresh build." diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e3adb126..378698cc 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -221,6 +221,26 @@ fn persistedAgentSessionId(session: *const SessionState, agent_type: ?[]const u8 return session.agent_session_id; } +/// Build "claude --resume " (etc.) from a restored entry and stash it on the session as +/// `resume_cmd`, to be injected as ARCHITECT_RESUME_CMD at spawn time. Must be called BEFORE +/// the session is spawned. No-op for entries without a captured agent session. +fn setResumeCommandFromEntry( + session: *SessionState, + entry: config_mod.Persistence.TerminalEntry, + allocator: std.mem.Allocator, +) void { + const agent_type_str = entry.agent_type orelse return; + const session_id = entry.agent_session_id orelse return; + if (session_id.len == 0) return; + const agent_kind = session_state.AgentKind.fromString(agent_type_str) orelse return; + const cmd = terminal_history.buildResumeCommand(allocator, agent_kind, session_id) catch |err| { + log.warn("failed to build resume command for slot {d}: {}", .{ session.slot_index, err }); + return; + }; + if (session.resume_cmd) |old| allocator.free(old); + session.resume_cmd = cmd; +} + fn seedSessionAgentMetadataFromEntry( session: *SessionState, entry: config_mod.Persistence.TerminalEntry, @@ -1226,6 +1246,14 @@ pub fn run() !void { errdefer persistence.deinit(allocator); persistence.font_size = std.math.clamp(persistence.font_size, min_font_size, max_font_size); + // Seed the live grid font scale: a persisted value (from a previous + // Cmd+Opt +/- adjustment) wins over the static [font]/[grid] config; then + // mirror it back so future persistence saves keep it in sync. + if (persistence.grid_font_scale != 0) { + config.grid.font_scale = std.math.clamp(persistence.grid_font_scale, config_mod.min_grid_font_scale, config_mod.max_grid_font_scale); + } + persistence.grid_font_scale = config.grid.font_scale; + // Initialize recent folders with home directory if empty if (persistence.recent_folders.items.len == 0) { if (std.posix.getenv("HOME")) |home| { @@ -1312,6 +1340,9 @@ pub fn run() !void { const renderer = sdl.renderer; var font_size: c_int = persistence.font_size; + // Whether the Architect window is the frontmost app (drives the accent + // overlay). Assume focused at launch; updated by SDL focus events. + var window_focused: bool = true; 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; @@ -1431,29 +1462,18 @@ pub fn run() !void { defer if (dir_buf) |buf| allocator.free(buf); if (dir_buf) |buf| { const dir: [:0]const u8 = buf[0..entry.path.len :0]; + + // Build the resume command BEFORE spawning: it is injected as + // ARCHITECT_RESUME_CMD into the shell env and run once by the wrapper rc + // after the user's rc loads — no stdin race, can't be eaten by a startup prompt. + setResumeCommandFromEntry(sessions[new_idx], entry, allocator); + sessions[new_idx].ensureSpawnedWithDir(dir, &loop) catch |err| { std.debug.print("Failed to spawn restored terminal {d}: {}\n", .{ new_idx, err }); }; if (sessions[new_idx].spawned) { seedSessionAgentMetadataFromEntry(sessions[new_idx], entry, allocator); } - - queueResumeCommand: { - if (!sessions[new_idx].spawned) break :queueResumeCommand; - const agent_type_str = entry.agent_type orelse break :queueResumeCommand; - const session_id = entry.agent_session_id orelse break :queueResumeCommand; - if (session_id.len == 0) break :queueResumeCommand; - const agent_kind = session_state.AgentKind.fromString(agent_type_str) orelse break :queueResumeCommand; - const resume_cmd = terminal_history.buildResumeCommand(allocator, agent_kind, session_id) catch |err| { - log.warn("failed to build resume command for session {d}: {}", .{ new_idx, err }); - break :queueResumeCommand; - }; - defer allocator.free(resume_cmd); - // Write to pending_write; PTY input queues until the shell reads it after startup. - sessions[new_idx].pending_write.appendSlice(allocator, resume_cmd) catch |err| { - log.warn("failed to queue resume command for session {d}: {}", .{ new_idx, err }); - }; - } } } @@ -1475,11 +1495,21 @@ pub fn run() !void { var quit_teardown = QuitTeardownState{}; defer quit_teardown.join(); - const initial_mode = initial_view_mode; + // Restore focus + zoom from persistence (2c): clamp the focused pane to the restored + // count, and only honor zoom when there is a pane to zoom into. The renderer computes + // the Full rect from the window each frame, so setting .Full directly is sufficient. + const restored_focus: usize = if (persistence.focused_session < initial_terminal_count) + persistence.focused_session + else + 0; + const initial_mode: app_state.ViewMode = if (persistence.zoomed and initial_terminal_count >= 1) + .Full + else + initial_view_mode; var anim_state = AnimationState{ .mode = initial_mode, - .focused_session = 0, - .previous_session = 0, + .focused_session = restored_focus, + .previous_session = restored_focus, .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 }, @@ -1533,8 +1563,10 @@ pub fn run() !void { 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()); + // Escape-hold-to-collapse is intentionally disabled: return-to-grid is now + // Cmd+Esc, and single/double Esc must pass straight through to the focused + // program (e.g. Claude Code's Esc-Esc rewind, vim's Esc). Leaving the + // EscapeHoldComponent unregistered keeps Escape a plain passthrough key. const hotkey_component = try ui_mod.hotkey_indicator.HotkeyIndicatorComponent.init(allocator, &ui_font); try ui.register(hotkey_component.asComponent()); ui.hotkey_component = hotkey_component; @@ -1714,6 +1746,7 @@ pub fn run() !void { savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); }, c.SDL_EVENT_WINDOW_FOCUS_LOST => { + window_focused = false; if (builtin.os.tag == .macos) { if (text_input_active) { platform.stopTextInput(sdl.window); @@ -1723,6 +1756,7 @@ pub fn run() !void { ime_composition.reset(); }, c.SDL_EVENT_WINDOW_FOCUS_GAINED => { + window_focused = true; if (builtin.os.tag == .macos) { input_source_tracker.restore() catch |err| { log.warn("Failed to restore input source: {}", .{err}); @@ -1823,6 +1857,30 @@ pub fn run() !void { continue; } + if (has_gui and !has_blocking_mod and key == c.SDLK_ESCAPE) { + // Cmd+Esc: collapse the focused pane back to the grid. + // (Plain Esc still passes through to the program.) + if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘⎋", now); + if (anim_state.mode == .Full and countSpawnedSessions(sessions) > 1) { + 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, + }; + } + } + 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; @@ -2037,31 +2095,57 @@ pub fn run() !void { std.debug.print("Paste failed: {}\n", .{err}); }; } else if (input.fontSizeShortcut(key, mod)) |direction| { + // Zoom is context-aware: in grid view it changes the GRID + // font size; in focus view it changes the FOCUS font size. + // The two are persisted independently and don't bleed into + // each other. 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; + if (anim_state.mode == .Grid or anim_state.mode == .GridResizing) { + const grid_scale_step: f32 = 0.1; + const delta: f32 = if (direction == .increase) grid_scale_step else -grid_scale_step; + const target_scale = std.math.clamp(config.grid.font_scale + delta, config_mod.min_grid_font_scale, config_mod.max_grid_font_scale); + if (target_scale != config.grid.font_scale) { + config.grid.font_scale = target_scale; + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + persistence.grid_font_scale = config.grid.font_scale; + persistence_dirty = true; + savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); + } + var grid_scale_buf: [64]u8 = undefined; + const grid_scale_msg = std.fmt.bufPrint(&grid_scale_buf, "Grid font: {d:.0}%", .{config.grid.font_scale * 100.0}) catch |err| blk: { + log.warn("failed to format grid scale toast: {}", .{err}); + break :blk "Grid font changed"; + }; + ui.showToast(grid_scale_msg, now); + } else { + 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; + // Grid text size scales with the focus font size, so + // compensate the grid scale to hold the grid steady. + const comp = @as(f32, @floatFromInt(font_size)) / @as(f32, @floatFromInt(target_size)); + config.grid.font_scale = std.math.clamp(config.grid.font_scale * comp, config_mod.min_grid_font_scale, config_mod.max_grid_font_scale); + font_size = target_size; - applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); - std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); + applyTerminalLayout(sessions, allocator, &font, render_width, render_height, ui_scale, &anim_state, grid.cols, grid.rows, config.grid.font_scale, &full_cols, &full_rows); + std.debug.print("Font size -> {d}px, terminal size: {d}x{d}\n", .{ font_size, full_cols, full_rows }); - persistence.font_size = font_size; - persistence_dirty = true; - savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); + persistence.font_size = font_size; + persistence.grid_font_scale = config.grid.font_scale; + persistence_dirty = true; + savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); + } + var notification_buf: [64]u8 = undefined; + const notification_msg = std.fmt.bufPrint(¬ification_buf, "Font size: {d}pt", .{font_size}) catch |err| blk: { + log.warn("failed to format font size toast: {}", .{err}); + break :blk "Font size changed"; + }; + ui.showToast(notification_msg, now); } - - var notification_buf: [64]u8 = undefined; - const notification_msg = std.fmt.bufPrint(¬ification_buf, "Font size: {d}pt", .{font_size}) catch |err| blk: { - log.warn("failed to format font size toast: {}", .{err}); - break :blk "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); @@ -2356,6 +2440,13 @@ pub fn run() !void { if (terminal_entries_changed) { persistence_dirty = true; } + // Persist focus + zoom so the active/zoomed pane is restored on relaunch (2c). + const cur_zoomed = anim_state.mode == .Full; + if (persistence.focused_session != anim_state.focused_session or persistence.zoomed != cur_zoomed) { + persistence.focused_session = anim_state.focused_session; + persistence.zoomed = cur_zoomed; + persistence_dirty = true; + } savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); if (quit_teardown.isFinished()) { @@ -2412,6 +2503,33 @@ pub fn run() !void { } allocator.free(s.path); }, + .agent_session => |s| { + // Live session-id capture from the agent's SessionStart hook. + // Free the heap strings on every exit path. + defer { + allocator.free(s.agent); + allocator.free(s.session_id); + } + const session_idx = findSessionIndexById(sessions, s.session) orelse continue; + const agent_kind = session_state.AgentKind.fromString(s.agent) orelse continue; + const session = sessions[session_idx]; + // Skip if nothing changed, to avoid needless persistence churn. + const unchanged = session.agent_metadata_captured and + session.agent_kind == agent_kind and + session.agent_session_id != null and + std.mem.eql(u8, session.agent_session_id.?, s.session_id); + if (unchanged) continue; + const id_dupe = allocator.dupe(u8, s.session_id) catch |err| { + log.warn("failed to store agent session id for slot {d}: {}", .{ session_idx, err }); + continue; + }; + if (session.agent_session_id) |old| allocator.free(old); + session.agent_kind = agent_kind; + session.agent_session_id = id_dupe; + session.agent_metadata_captured = true; + persistence_dirty = true; + log.info("captured agent session: slot {d} {s} {s}", .{ session_idx, s.agent, s.session_id }); + }, } } @@ -2492,6 +2610,21 @@ pub fn run() !void { } std.debug.print("Expanding session: {d}\n", .{idx}); }, + .SelectGridSession => |idx| { + // Single click in grid view: instantly move the focus/selection + // highlight to the clicked pane. No shell spawn, no zoom, and + // deliberately NO nav-wave animation. A mouse click is direct + // manipulation, so focus must change instantly (the moved accent + // border is the feedback); the wave stays on keyboard nav, where + // it answers "where did focus jump to?" (mirrors :focus-visible). + if (anim_state.mode != .Grid) continue; + if (idx >= sessions.len) continue; + if (idx == anim_state.focused_session) continue; + + session_interaction_component.clearSelection(anim_state.focused_session); + session_interaction_component.clearSelection(idx); + anim_state.focused_session = idx; + }, .DespawnSession => |idx| { if (idx < sessions.len) { if (anim_state.mode == .Full and anim_state.focused_session == idx) { @@ -3045,6 +3178,8 @@ pub fn run() !void { ui_scale, config.grid.font_scale, &grid, + window_focused, + @intCast(config.ui.inactive_overlay_alpha), ) catch |err| { log.err("render failed: {}", .{err}); return err; @@ -3086,26 +3221,25 @@ pub fn run() !void { for (quit_teardown.tasks[0..quit_teardown.task_count]) |task| { const session = sessions[task.session_idx]; session.stopQuitCapture(); - if (session.agent_session_id) |sid| { - allocator.free(sid); - session.agent_session_id = null; - } - session.agent_kind = null; - session.agent_metadata_captured = false; const text = session.quitCaptureBytes(); log.debug("quit teardown: session {d} extracted {d} bytes of terminal text", .{ task.session_idx, text.len }); if (terminal_history.extractLastUuid(text)) |uuid| { - log.info("quit teardown: session {d} captured session id: {s}", .{ task.session_idx, uuid }); - session.agent_kind = task.agent_kind; - session.agent_session_id = allocator.dupe(u8, uuid) catch |err| blk: { - log.warn("quit teardown: session {d} failed to allocate session id: {}", .{ task.session_idx, err }); - break :blk null; - }; - if (session.agent_session_id != null) { + // Scrape succeeded: overwrite with the freshest id from the farewell banner. + // Dup first so a failed alloc keeps the existing (hook-captured) id intact. + if (allocator.dupe(u8, uuid)) |new_id| { + if (session.agent_session_id) |sid| allocator.free(sid); + session.agent_session_id = new_id; + session.agent_kind = task.agent_kind; session.agent_metadata_captured = true; + log.info("quit teardown: session {d} captured session id: {s}", .{ task.session_idx, uuid }); + } else |err| { + log.warn("quit teardown: session {d} failed to allocate session id: {}", .{ task.session_idx, err }); } } else { - log.warn("quit teardown: session {d} agent {s} exited but no session id found in output", .{ task.session_idx, task.agent_kind.name() }); + // Scrape failed: KEEP whatever the SessionStart hook captured this run instead + // of wiping it. The live hook is the reliable source of the resume id; the quit + // scrape is only a best-effort fallback for agents without the hook. + log.warn("quit teardown: session {d} agent {s} exited; no id in farewell output, keeping live-captured id (captured={})", .{ task.session_idx, task.agent_kind.name(), session.agent_metadata_captured }); } } } diff --git a/src/app/terminal_history.zig b/src/app/terminal_history.zig index 2f1fd522..adb9331a 100644 --- a/src/app/terminal_history.zig +++ b/src/app/terminal_history.zig @@ -153,13 +153,15 @@ fn matchUuidAt(text: []const u8, start: usize) ?[]const u8 { return text[start..pos]; } -/// Builds the shell command to resume an agent session, including a trailing newline. -/// Caller owns the returned slice and must free it. +/// Builds the shell command to resume an agent session (no trailing newline). It is injected +/// as ARCHITECT_RESUME_CMD and `eval`'d by the wrapper rc after the user's shell rc loads — +/// not typed into the PTY — so it can't be eaten by a startup prompt. Caller owns the +/// returned slice and must free it. pub fn buildResumeCommand(allocator: std.mem.Allocator, agent_kind: AgentKind, session_id: []const u8) ![]u8 { return switch (agent_kind) { - .claude => std.fmt.allocPrint(allocator, "claude --resume {s}\n", .{session_id}), - .codex => std.fmt.allocPrint(allocator, "codex resume {s}\n", .{session_id}), - .gemini => std.fmt.allocPrint(allocator, "gemini --resume {s}\n", .{session_id}), + .claude => std.fmt.allocPrint(allocator, "claude --resume {s}", .{session_id}), + .codex => std.fmt.allocPrint(allocator, "codex resume {s}", .{session_id}), + .gemini => std.fmt.allocPrint(allocator, "gemini --resume {s}", .{session_id}), }; } diff --git a/src/colors.zig b/src/colors.zig index b894d4c0..b886d2a3 100644 --- a/src/colors.zig +++ b/src/colors.zig @@ -138,10 +138,10 @@ test "colorsEqual" { test "Theme.default" { const theme = Theme.default(); - // Background should be One Dark background - try std.testing.expectEqual(@as(u8, 14), theme.background.r); - try std.testing.expectEqual(@as(u8, 17), theme.background.g); - try std.testing.expectEqual(@as(u8, 22), theme.background.b); + // Background should be the Cacha-matched dark background (#262624) + try std.testing.expectEqual(@as(u8, 38), theme.background.r); + try std.testing.expectEqual(@as(u8, 38), theme.background.g); + try std.testing.expectEqual(@as(u8, 36), theme.background.b); // Foreground should be One Dark bright white try std.testing.expectEqual(@as(u8, 205), theme.foreground.r); diff --git a/src/config.zig b/src/config.zig index 2acdf28b..f5ddb426 100644 --- a/src/config.zig +++ b/src/config.zig @@ -10,7 +10,8 @@ pub const Color = struct { g: u8, b: u8, - pub const default_background: Color = .{ .r = 14, .g = 17, .b = 22 }; + // Cacha dark theme background (#262624) to match the companion app. + pub const default_background: Color = .{ .r = 38, .g = 38, .b = 36 }; pub const default_foreground: Color = .{ .r = 205, .g = 214, .b = 224 }; pub const default_accent: Color = .{ .r = 97, .g = 175, .b = 239 }; pub const default_selection: Color = .{ .r = 27, .g = 34, .b = 48 }; @@ -34,7 +35,14 @@ pub const Color = struct { }; pub const FontConfig = struct { + /// Font size (points) used in focused/full view. Also the base size the + /// dynamic grid scaling is derived from. Adjustable at runtime via Cmd+±. size: i32 = 14, + /// Grid-mode text-size multiplier. 0 = "unset" -> fall back to + /// [grid] font_scale (which itself defaults to 1.0). Larger = bigger text + /// in the small grid panes (and proportionally fewer cols/rows per pane). + /// Clamped to [min_grid_font_scale, max_grid_font_scale] on load. + grid_scale: f32 = 0, family: ?[]const u8 = null, family_owned: bool = false, @@ -51,6 +59,7 @@ pub const FontConfig = struct { pub fn duplicate(self: FontConfig, allocator: std.mem.Allocator) !FontConfig { return FontConfig{ .size = self.size, + .grid_scale = self.grid_scale, .family = if (self.family) |f| try allocator.dupe(u8, f) else null, .family_owned = self.family != null, }; @@ -71,6 +80,10 @@ pub const GridConfig = struct { pub const UiConfig = struct { show_hotkey_feedback: bool = true, enable_animations: bool = true, + /// Alpha (0-255) of the accent overlay drawn over every pane when the + /// Architect window is NOT the frontmost app. 0 disables the overlay. + /// Higher = panes look more clearly "inactive" when you tab away. + inactive_overlay_alpha: i32 = 130, }; pub const PaletteConfig = struct { @@ -326,6 +339,13 @@ pub const Persistence = struct { terminal_entries: std.ArrayListUnmanaged(TerminalEntry) = .{}, recent_folders: std.ArrayListUnmanaged(RecentFolder) = .{}, visit_counter: u32 = 0, + /// Focused pane index and whether it was zoomed (Full mode) at save time, so the + /// active/zoomed pane is restored on relaunch instead of defaulting to the first. + focused_session: usize = 0, + zoomed: bool = false, + /// Live grid-pane font-scale multiplier (Cmd+Opt +/-), persisted so it + /// survives relaunch. 0 = unset -> fall back to [font]/[grid] config. + grid_font_scale: f32 = 0, const TomlPersistenceV3 = struct { window: WindowConfig = .{}, @@ -334,6 +354,9 @@ pub const Persistence = struct { terminal_agent_types: ?[]const []const u8 = null, terminal_session_ids: ?[]const []const u8 = null, recent_folders: ?toml.HashMap(u32) = null, + focused_session: usize = 0, + zoomed: bool = false, + grid_font_scale: f32 = 0, }; const TomlPersistenceV2 = struct { @@ -386,6 +409,9 @@ pub const Persistence = struct { defer result.deinit(); persistence.window = result.value.window; persistence.font_size = result.value.font_size; + persistence.focused_session = result.value.focused_session; + persistence.zoomed = result.value.zoomed; + persistence.grid_font_scale = result.value.grid_font_scale; if (result.value.terminals) |paths| { for (paths, 0..) |path, idx| { @@ -477,8 +503,11 @@ pub const Persistence = struct { } pub fn serializeToWriter(self: Persistence, writer: anytype) !void { - // Write font_size first (top-level scalar) + // Write top-level scalars first (must precede any [section] in TOML). try writer.print("font_size = {d}\n", .{self.font_size}); + try writer.print("focused_session = {d}\n", .{self.focused_session}); + try writer.print("zoomed = {}\n", .{self.zoomed}); + try writer.print("grid_font_scale = {d:.3}\n", .{self.grid_font_scale}); // Write terminal path and agent arrays before any sections if (self.terminal_entries.items.len > 0) { @@ -817,10 +846,21 @@ pub const Config = struct { \\# Font options \\# [font] \\# family = "SFNSMono" + \\# size = 14 # focused-view font size (points) + \\# grid_scale = 1.0 # grid-view text size multiplier (0.5-3.0) + \\# # Cmd +/- zooms whichever view you're in (grid vs focus); + \\# # the two sizes are independent and remembered across launches. \\ \\# Grid options (grid size is dynamic based on terminal count) \\# [grid] - \\# font_scale = 1.0 + \\# font_scale = 1.0 # legacy alias of [font] grid_scale + \\ + \\# Keybindings (not configurable): + \\# Move grid selection: Cmd+Arrow, or single-click a pane + \\# Focus a pane: double-click it, or Cmd+Return on the selection + \\# Back to grid: Cmd+Esc (single/double Esc pass through to the program) + \\# Typing: keys/Enter type into the selected grid pane as usual + \\# Zoom a view: Cmd +/- (grid size in grid view, focus size in focus view; both persisted) \\ \\# Rendering options \\# [rendering] @@ -830,10 +870,11 @@ pub const Config = struct { \\# [ui] \\# show_hotkey_feedback = true \\# enable_animations = true + \\# inactive_overlay_alpha = 130 # 0-255 accent dim over panes when app is not frontmost (0 = off) \\ \\# Theme colors (hex format) \\# [theme] - \\# background = "#0E1116" + \\# background = "#262624" \\# foreground = "#CDD6E0" \\# selection = "#1B2230" \\# accent = "#61AFEF" @@ -896,8 +937,17 @@ pub const Config = struct { var config = result.value; + // [font] grid_scale (if set, sentinel != 0) takes precedence over the + // legacy [grid] font_scale knob; otherwise keep whatever grid set. + if (config.font.grid_scale != 0) { + config.grid.font_scale = config.font.grid_scale; + } config.grid.font_scale = std.math.clamp(config.grid.font_scale, min_grid_font_scale, max_grid_font_scale); + // Keep the overlay alpha in range so a bad value in the TOML can never + // hide the panes entirely. + config.ui.inactive_overlay_alpha = std.math.clamp(config.ui.inactive_overlay_alpha, 0, 255); + config.font = try config.font.duplicate(allocator); config.theme = try config.theme.duplicate(allocator); config.logging = try config.logging.duplicate(allocator); @@ -968,9 +1018,9 @@ test "ThemeConfig - default colors" { const theme = ThemeConfig{}; const bg = theme.getBackground(); - try std.testing.expectEqual(@as(u8, 14), bg.r); - try std.testing.expectEqual(@as(u8, 17), bg.g); - try std.testing.expectEqual(@as(u8, 22), bg.b); + try std.testing.expectEqual(@as(u8, 38), bg.r); + try std.testing.expectEqual(@as(u8, 38), bg.g); + try std.testing.expectEqual(@as(u8, 36), bg.b); const fg = theme.getForeground(); try std.testing.expectEqual(@as(u8, 205), fg.r); @@ -995,6 +1045,41 @@ test "ThemeConfig - custom colors" { try std.testing.expectEqual(@as(u8, 0), fg.b); } +test "font grid_scale / inactive overlay - defaults and parsing" { + const allocator = std.testing.allocator; + + // Defaults when omitted. + { + var parser = toml.Parser(Config).init(allocator); + defer parser.deinit(); + var result = try parser.parseString("[font]\nsize = 14\n"); + defer result.deinit(); + const config = result.value; + try std.testing.expectEqual(@as(f32, 0), config.font.grid_scale); + try std.testing.expectEqual(@as(i32, 130), config.ui.inactive_overlay_alpha); + } + + // Explicit values parse into the expected fields. + { + const content = + \\[font] + \\size = 15 + \\grid_scale = 1.5 + \\ + \\[ui] + \\inactive_overlay_alpha = 90 + \\ + ; + var parser = toml.Parser(Config).init(allocator); + defer parser.deinit(); + var result = try parser.parseString(content); + defer result.deinit(); + const config = result.value; + try std.testing.expectApproxEqAbs(@as(f32, 1.5), config.font.grid_scale, 0.0001); + try std.testing.expectEqual(@as(i32, 90), config.ui.inactive_overlay_alpha); + } +} + test "Config - decode sectioned toml" { const allocator = std.testing.allocator; @@ -1180,6 +1265,7 @@ test "Persistence save/load round-trip preserves all fields" { original.window.x = 100; original.window.y = 200; original.font_size = 16; + original.grid_font_scale = 1.5; try original.appendTerminalEntry(allocator, "/home/user/project1", null, null); try original.appendTerminalEntry(allocator, "/home/user/project2", "claude", "abc-123-def"); try original.appendTerminalEntry(allocator, "/tmp/test", null, null); @@ -1202,6 +1288,7 @@ test "Persistence save/load round-trip preserves all fields" { loaded.window = result.value.window; loaded.font_size = result.value.font_size; + loaded.grid_font_scale = result.value.grid_font_scale; if (result.value.terminals) |paths| { for (paths, 0..) |path, idx| { @@ -1222,6 +1309,7 @@ test "Persistence save/load round-trip preserves all fields" { try std.testing.expectEqual(original.window.x, loaded.window.x); try std.testing.expectEqual(original.window.y, loaded.window.y); try std.testing.expectEqual(original.font_size, loaded.font_size); + try std.testing.expectApproxEqAbs(original.grid_font_scale, loaded.grid_font_scale, 0.001); try std.testing.expectEqual(original.terminal_entries.items.len, loaded.terminal_entries.items.len); for (original.terminal_entries.items, loaded.terminal_entries.items) |orig, loaded_entry| { diff --git a/src/os/open.zig b/src/os/open.zig index c97b8a6d..7234d76d 100644 --- a/src/os/open.zig +++ b/src/os/open.zig @@ -8,43 +8,41 @@ const OpenError = error{ OutOfMemory, }; -const argv_len: comptime_int = switch (builtin.os.tag) { - .linux, .freebsd => 2, - .windows => 3, - .macos => 2, - else => @compileError("unsupported platform for openUrl"), -}; - const ThreadContext = struct { allocator: std.mem.Allocator, - url: []const u8, - argv: [argv_len][]const u8, + argv: [][]u8, fn deinit(self: *ThreadContext) void { - self.allocator.free(self.url); + for (self.argv) |arg| self.allocator.free(arg); + self.allocator.free(self.argv); self.allocator.destroy(self); } }; -pub fn openUrl(_: std.mem.Allocator, url: []const u8) OpenError!void { - // Use c_allocator because it's thread-safe and the context is freed on a worker thread. - const thread_allocator = std.heap.c_allocator; - - const ctx = thread_allocator.create(ThreadContext) catch return error.OutOfMemory; - errdefer thread_allocator.destroy(ctx); +/// Spawn an opener (`open` / `xdg-open` / ...) on a detached thread so the UI +/// never blocks. Every argument is duped into the thread-safe c_allocator, so +/// the caller may free its own strings as soon as this returns. +fn spawnDetached(argv: []const []const u8) OpenError!void { + const a = std.heap.c_allocator; - ctx.allocator = thread_allocator; - ctx.url = thread_allocator.dupe(u8, url) catch return error.OutOfMemory; - errdefer thread_allocator.free(ctx.url); + const ctx = a.create(ThreadContext) catch return error.OutOfMemory; + errdefer a.destroy(ctx); + ctx.allocator = a; - ctx.argv = switch (builtin.os.tag) { - .linux, .freebsd => .{ "xdg-open", ctx.url }, - .windows => .{ "rundll32", "url.dll,FileProtocolHandler", ctx.url }, - .macos => .{ "open", ctx.url }, - else => comptime unreachable, - }; + const owned = a.alloc([]u8, argv.len) catch return error.OutOfMemory; + var filled: usize = 0; + errdefer { + for (owned[0..filled]) |s| a.free(s); + a.free(owned); + } + for (argv, 0..) |arg, i| { + owned[i] = a.dupe(u8, arg) catch return error.OutOfMemory; + filled = i + 1; + } + ctx.argv = owned; - const thread = std.Thread.spawn(.{}, openUrlThread, .{ctx}) catch |err| { + const thread = std.Thread.spawn(.{}, openThread, .{ctx}) catch |err| { + ctx.deinit(); return switch (err) { error.OutOfMemory => error.OutOfMemory, else => error.SpawnFailed, @@ -53,12 +51,39 @@ pub fn openUrl(_: std.mem.Allocator, url: []const u8) OpenError!void { thread.detach(); } -fn openUrlThread(ctx: *ThreadContext) void { +fn openThread(ctx: *ThreadContext) void { defer ctx.deinit(); - var child = std.process.Child.init(&ctx.argv, ctx.allocator); + var child = std.process.Child.init(ctx.argv, ctx.allocator); _ = child.spawnAndWait() catch |err| { - log.warn("failed to open URL '{s}': {}", .{ ctx.url, err }); + log.warn("failed to spawn opener: {}", .{err}); return; }; } + +/// Open a web URL (or any target) with the platform's default handler. +pub fn openUrl(_: std.mem.Allocator, url: []const u8) OpenError!void { + const argv: []const []const u8 = switch (builtin.os.tag) { + .linux, .freebsd => &.{ "xdg-open", url }, + .windows => &.{ "rundll32", "url.dll,FileProtocolHandler", url }, + .macos => &.{ "open", url }, + else => @compileError("unsupported platform for openUrl"), + }; + return spawnDetached(argv); +} + +/// Open a clicked link target. On macOS: a local file path (absolute) opens in +/// VS Code, EXCEPT `.html`/`.htm`, which open with the default handler (the +/// user's browser). Web URLs and everything else fall back to the default +/// handler. Other platforms always use the default handler. +pub fn openTarget(allocator: std.mem.Allocator, target: []const u8) OpenError!void { + if (builtin.os.tag == .macos and target.len > 0 and target[0] == '/') { + const ext = std.fs.path.extension(target); + const is_html = std.ascii.eqlIgnoreCase(ext, ".html") or std.ascii.eqlIgnoreCase(ext, ".htm"); + if (is_html) { + return spawnDetached(&.{ "open", target }); + } + return spawnDetached(&.{ "open", "-a", "Visual Studio Code", target }); + } + return openUrl(allocator, target); +} diff --git a/src/path_matcher.zig b/src/path_matcher.zig new file mode 100644 index 00000000..b2b09038 --- /dev/null +++ b/src/path_matcher.zig @@ -0,0 +1,100 @@ +const std = @import("std"); + +/// A filesystem-path token detected in a line of terminal text. `path` is a +/// slice into the input `text` with any trailing `:line[:col]` reference and +/// trailing sentence dots already excluded. Resolving it against a working +/// directory and checking that it exists on disk is the caller's job. +pub const PathMatch = struct { + path: []const u8, + start: usize, + end: usize, +}; + +/// Characters that can appear in a path token. Note `:` is intentionally NOT +/// included, so a "foo.zig:42" line reference naturally splits at the colon and +/// the matched token is just the path. +fn isPathChar(ch: u8) bool { + return switch (ch) { + 'a'...'z', 'A'...'Z', '0'...'9' => true, + '/', '.', '_', '-', '~' => true, + else => false, + }; +} + +/// Heuristic: a token is "path-like" if it is absolute (`/…`), home-relative +/// (`~…`), contains a directory separator, or has a dotted name (`foo.zig`). +/// Plain words like "hello" are rejected so we don't try to stat every word. +/// The caller's existence check is the real filter; this just avoids needless +/// filesystem lookups. +fn looksLikePath(token: []const u8) bool { + if (token.len == 0) return false; + if (std.mem.eql(u8, token, ".") or std.mem.eql(u8, token, "..")) return false; + if (token[0] == '/' or token[0] == '~') return true; + var has_slash = false; + var has_dot = false; + for (token) |ch| { + if (ch == '/') has_slash = true; + if (ch == '.') has_dot = true; + } + return has_slash or has_dot; +} + +/// Find a path-like token at byte position `col` in `text`. Returns null if the +/// character under the cursor isn't part of a path-like token. +pub fn findPathMatchAtPosition(text: []const u8, col: usize) ?PathMatch { + if (text.len == 0 or col >= text.len) return null; + if (!isPathChar(text[col])) return null; + + var start = col; + while (start > 0 and isPathChar(text[start - 1])) start -= 1; + var end = col + 1; + while (end < text.len and isPathChar(text[end])) end += 1; + + // Trim trailing sentence dots ("see foo.zig." -> "foo.zig"), keeping at + // least one character. + while (end - start > 1 and text[end - 1] == '.') end -= 1; + + const token = text[start..end]; + if (!looksLikePath(token)) return null; + + return PathMatch{ .path = token, .start = start, .end = end }; +} + +test "findPathMatchAtPosition - absolute path" { + const t = "edited /Users/roba/dev/x.zig now"; + const m = findPathMatchAtPosition(t, 12) orelse return error.TestExpectedMatch; + try std.testing.expectEqualStrings("/Users/roba/dev/x.zig", m.path); + try std.testing.expectEqual(@as(usize, 7), m.start); +} + +test "findPathMatchAtPosition - relative with line suffix" { + const t = "see src/config.zig:42 here"; + const m = findPathMatchAtPosition(t, 6) orelse return error.TestExpectedMatch; + try std.testing.expectEqualStrings("src/config.zig", m.path); +} + +test "findPathMatchAtPosition - trailing dot trimmed" { + const t = "open foo.zig."; + const m = findPathMatchAtPosition(t, 7) orelse return error.TestExpectedMatch; + try std.testing.expectEqualStrings("foo.zig", m.path); +} + +test "findPathMatchAtPosition - tilde home path" { + const t = "~/.config/architect/config.toml"; + const m = findPathMatchAtPosition(t, 5) orelse return error.TestExpectedMatch; + try std.testing.expectEqualStrings("~/.config/architect/config.toml", m.path); +} + +test "findPathMatchAtPosition - plain words are not paths" { + try std.testing.expect(findPathMatchAtPosition("hello world", 1) == null); + try std.testing.expect(findPathMatchAtPosition("just text here", 6) == null); +} + +test "findPathMatchAtPosition - bare dot and dotdot rejected" { + try std.testing.expect(findPathMatchAtPosition("cd .. now", 3) == null); + try std.testing.expect(findPathMatchAtPosition("a . b", 2) == null); +} + +test "findPathMatchAtPosition - click off a path char returns null" { + try std.testing.expect(findPathMatchAtPosition("a/b c", 3) == null); // space +} diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 0d0f9388..89565b66 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -110,6 +110,8 @@ pub fn render( ui_scale: f32, grid_font_scale: f32, grid: ?*const GridLayout, + window_focused: bool, + inactive_overlay_alpha: u8, ) RenderError!void { _ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255); _ = c.SDL_RenderClear(renderer); @@ -319,6 +321,30 @@ pub fn render( } }, } + + // App-focus indicator: when the Architect window is NOT the frontmost app, + // wash ONLY the focused/selected pane in the accent colour, so you can tell + // which pane is active and that the app is in the background. When the + // window is focused, that pane shows only its border (no fill). + if (!window_focused and inactive_overlay_alpha > 0) { + const focused_rect = switch (anim_state.mode) { + .Grid, .GridResizing => Rect{ + .x = @as(c_int, @intCast(anim_state.focused_session % grid_cols)) * cell_width_pixels, + .y = @as(c_int, @intCast(anim_state.focused_session / grid_cols)) * cell_height_pixels, + .w = cell_width_pixels, + .h = cell_height_pixels, + }, + else => Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height }, + }; + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, theme.accent.r, theme.accent.g, theme.accent.b, inactive_overlay_alpha); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(focused_rect.x), + .y = @floatFromInt(focused_rect.y), + .w = @floatFromInt(focused_rect.w), + .h = @floatFromInt(focused_rect.h), + }); + } } fn renderSession( @@ -707,7 +733,10 @@ fn renderSessionOverlays( } if (is_focused) { - const focus_blue = theme.palette[12]; + // Selected pane: accent-coloured border ONLY (no fill). The accent + // fill that signals "window not focused" is drawn over just this + // pane in render(); when focused, the pane shows only this border. + const focus_accent = theme.accent; const inset: c_int = if (has_attention) border_thickness else 0; var focus_rect = rect; focus_rect.x += inset; @@ -715,17 +744,7 @@ fn renderSessionOverlays( focus_rect.w -= inset * 2; focus_rect.h -= inset * 2; if (focus_rect.w > 0 and focus_rect.h > 0) { - _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); - if (!has_attention) { - _ = c.SDL_SetRenderDrawColor(renderer, focus_blue.r, focus_blue.g, focus_blue.b, 38); - _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ - .x = @floatFromInt(focus_rect.x), - .y = @floatFromInt(focus_rect.y), - .w = @floatFromInt(focus_rect.w), - .h = @floatFromInt(focus_rect.h), - }); - } - primitives.drawThickBorder(renderer, focus_rect, border_thickness, border_radius, focus_blue); + primitives.drawThickBorder(renderer, focus_rect, border_thickness, border_radius, focus_accent); } } } @@ -1125,10 +1144,6 @@ fn applyTvOverlay(renderer: *c.SDL_Renderer, rect: Rect, is_focused: bool, theme const radius: c_int = 12; - const bg = theme.background; - _ = c.SDL_SetRenderDrawColor(renderer, bg.r, bg.g, bg.b, 60); - primitives.fillRoundedRect(renderer, rect, radius); - const border_color = if (is_focused) blk: { const acc = theme.accent; break :blk c.SDL_Color{ .r = acc.r, .g = acc.g, .b = acc.b, .a = 190 }; diff --git a/src/session/notify.zig b/src/session/notify.zig index 2b17e6b1..f1dc4547 100644 --- a/src/session/notify.zig +++ b/src/session/notify.zig @@ -8,6 +8,7 @@ const log = std.log.scoped(.notify); pub const Notification = union(enum) { status: StatusNotification, story: StoryNotification, + agent_session: AgentSessionNotification, }; pub const StatusNotification = struct { @@ -21,6 +22,17 @@ pub const StoryNotification = struct { path: []const u8, }; +/// Reports the live agent (e.g. Claude Code) session id for a pane, sent by the +/// agent's SessionStart hook. Lets Architect capture the resume id reliably the +/// moment a session starts — instead of scraping it during quit teardown. +pub const AgentSessionNotification = struct { + session: usize, + /// Heap-allocated agent name (e.g. "claude"); caller must free. + agent: []const u8, + /// Heap-allocated session id (UUID); caller must free. + session_id: []const u8, +}; + pub const NotificationQueue = struct { mutex: std.Thread.Mutex = .{}, items: std.ArrayListUnmanaged(Notification) = .{}, @@ -75,12 +87,17 @@ fn notificationSessionId(note: Notification) usize { return switch (note) { .status => |s| s.session, .story => |s| s.session, + .agent_session => |s| s.session, }; } fn releaseNotification(allocator: std.mem.Allocator, note: Notification) void { switch (note) { .story => |s| allocator.free(s.path), + .agent_session => |s| { + allocator.free(s.agent); + allocator.free(s.session_id); + }, .status => {}, } } @@ -150,6 +167,27 @@ pub fn startNotifyThread( .path = path_dupe, } }; } + if (tv == .string and std.mem.eql(u8, tv.string, "agent_session")) { + const agent_val = obj.get("agent") orelse return null; + const session_id_val = obj.get("session_id") orelse return null; + if (agent_val != .string or session_id_val != .string) return null; + if (session_id_val.string.len == 0) return null; + // Allocate with persistent allocator so they survive arena cleanup. + const agent_dupe = persistent_alloc.dupe(u8, agent_val.string) catch |err| { + log.err("failed to duplicate agent name for session {d}: {}", .{ session_idx, err }); + return null; + }; + const session_id_dupe = persistent_alloc.dupe(u8, session_id_val.string) catch |err| { + log.err("failed to duplicate agent session id for session {d}: {}", .{ session_idx, err }); + persistent_alloc.free(agent_dupe); + return null; + }; + return Notification{ .agent_session = .{ + .session = session_idx, + .agent = agent_dupe, + .session_id = session_id_dupe, + } }; + } } // Default: status notification diff --git a/src/session/state.zig b/src/session/state.zig index 59462c7d..efe66ad9 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -122,6 +122,11 @@ pub const SessionState = struct { /// True only when agent_kind/agent_session_id were captured during the current run's quit flow. /// Restored metadata is used for startup resume injection, but must not be re-persisted as fresh data. agent_metadata_captured: bool = false, + /// Resume command (e.g. "claude --resume ") injected as ARCHITECT_RESUME_CMD into the + /// next spawn's environment. The wrapper rc runs it ONCE after the user's shell rc finishes + /// loading — so it never races stdin and can't be eaten by a startup prompt. Owned; freed in + /// teardown. Null for normal (non-restored) panes. + resume_cmd: ?[]u8 = null, /// Raw PTY output captured after quit teardown starts. Used to avoid extracting /// stale UUIDs from earlier scrollback. quit_capture: std.ArrayListUnmanaged(u8) = .empty, @@ -207,6 +212,7 @@ pub const SessionState = struct { &self.session_id_z, self.notify_sock_z, working_dir, + self.resume_cmd, ); errdefer { var s = shell; @@ -306,6 +312,11 @@ pub const SessionState = struct { self.agent_kind = null; self.agent_metadata_captured = false; + if (self.resume_cmd) |cmd| { + allocator.free(cmd); + self.resume_cmd = null; + } + if (self.cwd_path) |path| { allocator.free(path); self.cwd_path = null; diff --git a/src/shell.zig b/src/shell.zig index e6c82c62..74c5a4b7 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -68,7 +68,9 @@ const architect_command_script = \\ \\CLAUDE_DONE = "architect notify done || true" \\CLAUDE_APPROVAL = "architect notify awaiting_approval || true" + \\CLAUDE_SESSION = "architect agent-session || true" \\CLAUDE_NEEDLES = ("architect notify", "architect_notify.py") + \\CLAUDE_SESSION_NEEDLES = ("architect agent-session",) \\ \\GEMINI_DONE = "python3 ~/.gemini/architect_hook.py done" \\GEMINI_APPROVAL = "python3 ~/.gemini/architect_hook.py awaiting_approval" @@ -96,6 +98,44 @@ const architect_command_script = \\ except Exception: \\ pass \\ + \\def send_agent_session(session_id: str) -> None: + \\ slot = os.environ.get("ARCHITECT_SESSION_ID") + \\ sock_path = os.environ.get("ARCHITECT_NOTIFY_SOCK") + \\ if not slot or not sock_path or not session_id: + \\ return + \\ try: + \\ message = json.dumps({ + \\ "session": int(slot), + \\ "type": "agent_session", + \\ "agent": "claude", + \\ "session_id": session_id, + \\ }) + "\n" + \\ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + \\ sock.connect(sock_path) + \\ sock.sendall(message.encode()) + \\ sock.close() + \\ except Exception: + \\ pass + \\ + \\def cmd_agent_session() -> int: + \\ # Invoked as a Claude Code SessionStart hook. Claude pipes the hook event + \\ # JSON (which carries "session_id") to stdin; forward that id to Architect. + \\ try: + \\ raw = sys.stdin.read() + \\ except Exception: + \\ return 0 + \\ if not raw.strip(): + \\ return 0 + \\ try: + \\ payload = json.loads(raw) + \\ except Exception: + \\ return 0 + \\ if isinstance(payload, dict): + \\ session_id = payload.get("session_id") + \\ if isinstance(session_id, str) and session_id: + \\ send_agent_session(session_id) + \\ return 0 + \\ \\def state_from_notification(raw: str) -> str | None: \\ raw = raw.strip() \\ if not raw: @@ -310,6 +350,13 @@ const architect_command_script = \\ group["hooks"].append({"type": "command", "command": CLAUDE_APPROVAL}) \\ changed = True \\ + \\ session_groups = hooks.setdefault("SessionStart", []) + \\ if not hooks_have_needles(session_groups, CLAUDE_SESSION_NEEDLES): + \\ # Append a fresh matcher-less group so it runs on every session start + \\ # (startup/resume/clear/compact), independent of any existing groups. + \\ session_groups.append({"hooks": [{"type": "command", "command": CLAUDE_SESSION}]}) + \\ changed = True + \\ \\ return changed \\ \\def ensure_gemini_hooks(data: dict) -> bool: @@ -447,6 +494,8 @@ const architect_command_script = \\ return 0 \\ notify_architect(state) \\ return 0 + \\ if cmd == "agent-session": + \\ return cmd_agent_session() \\ if cmd == "hook": \\ if len(sys.argv) < 3: \\ print_usage() @@ -607,6 +656,17 @@ const architect_zsh_rc_script = \\unset orig \\unset wrapper \\unset source_dir + \\# Architect: run the restore/resume command ONCE, after the user's rc has + \\# finished loading (so `claude`/PATH are ready) and stdin is the live tty. + \\# It is executed, never typed into the PTY, so a startup prompt (e.g. an + \\# oh-my-zsh update [Y/n]) cannot swallow it. Unset first so the launched + \\# program and any nested shells do not re-run it. + \\if [ -n "${ARCHITECT_RESUME_CMD:-}" ]; then + \\ architect__resume_cmd="$ARCHITECT_RESUME_CMD" + \\ unset ARCHITECT_RESUME_CMD + \\ eval "$architect__resume_cmd" + \\ unset architect__resume_cmd + \\fi \\ ; @@ -1020,8 +1080,9 @@ pub const Shell = struct { const name_session: [:0]const u8 = "ARCHITECT_SESSION_ID\x00"; const name_sock: [:0]const u8 = "ARCHITECT_NOTIFY_SOCK\x00"; + const name_resume: [:0]const u8 = "ARCHITECT_RESUME_CMD\x00"; - pub fn spawn(shell_path: []const u8, size: pty_mod.winsize, session_id: [:0]const u8, notify_sock: [:0]const u8, working_dir: ?[:0]const u8) SpawnError!Shell { + pub fn spawn(shell_path: []const u8, size: pty_mod.winsize, session_id: [:0]const u8, notify_sock: [:0]const u8, working_dir: ?[:0]const u8, resume_cmd: ?[]const u8) SpawnError!Shell { // Ensure terminfo is set up (parent process, before fork) ensureTerminfoSetup(); ensureArchitectCommandSetup(); @@ -1052,6 +1113,18 @@ pub const Shell = struct { if (libc.setenv(name_sock.ptr, notify_sock, 1) != 0) { std.c._exit(1); } + if (resume_cmd) |rc| { + // Null-terminate on the stack (no sentinel heap alloc) for setenv. + var rbuf: [512]u8 = undefined; + if (rc.len < rbuf.len) { + @memcpy(rbuf[0..rc.len], rc); + rbuf[rc.len] = 0; + const rc_z = rbuf[0..rc.len :0]; + if (libc.setenv(name_resume.ptr, rc_z, 1) != 0) { + std.c._exit(1); + } + } + } // Finder launches provide a nearly empty environment; seed common // terminal vars so shells behave like real terminals (color, terminfo). diff --git a/src/ui/components/help_overlay.zig b/src/ui/components/help_overlay.zig index a74a7b81..3ef87d00 100644 --- a/src/ui/components/help_overlay.zig +++ b/src/ui/components/help_overlay.zig @@ -11,16 +11,17 @@ const ExpandingOverlay = @import("expanding_overlay.zig").ExpandingOverlay; const Shortcut = struct { key: []const u8, desc: []const u8 }; const shortcuts = [_]Shortcut{ - .{ .key = "Click terminal", .desc = "Expand to full screen" }, - .{ .key = "ESC (hold)", .desc = "Collapse to grid view" }, + .{ .key = "Click pane", .desc = "Move focus (grid view)" }, + .{ .key = "Double-click pane", .desc = "Expand to full screen" }, + .{ .key = "⌘ESC", .desc = "Collapse to grid view" }, .{ .key = "⌘↑/↓/←/→", .desc = "Navigate grid" }, .{ .key = "⌘1–⌘9/⌘0", .desc = "Jump to a grid slot" }, - .{ .key = "⌘↵", .desc = "Expand focused terminal" }, + .{ .key = "⌘↵", .desc = "Focus selected pane" }, .{ .key = "⌘T", .desc = "Open worktree picker" }, .{ .key = "⌘O", .desc = "Open recent folders" }, .{ .key = "⌘?", .desc = "Open help" }, .{ .key = "⌘N", .desc = "Spawn new terminal" }, - .{ .key = "⌘⇧+ / ⌘⇧-", .desc = "Adjust font size" }, + .{ .key = "⌘+ / ⌘-", .desc = "Zoom (grid or focus view)" }, .{ .key = "⌘D", .desc = "Show git diff" }, .{ .key = "⌘R", .desc = "Open reader mode" }, .{ .key = "⌘K", .desc = "Clear terminal" }, diff --git a/src/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index 14c0b040..4b16d739 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -8,6 +8,7 @@ const renderer_mod = @import("../../render/renderer.zig"); const dpi = @import("../../dpi.zig"); const session_state = @import("../../session/state.zig"); const url_matcher = @import("../../url_matcher.zig"); +const path_matcher = @import("../../path_matcher.zig"); const font_mod = @import("../../font.zig"); const app_state = @import("../../app/app_state.zig"); const types = @import("../types.zig"); @@ -180,9 +181,20 @@ pub const SessionInteractionComponent = struct { const clicked_session: usize = grid_row_idx * host.grid_cols + grid_col_idx; if (clicked_session >= self.sessions.len) return false; - actions.append(.{ .FocusSession = clicked_session }) catch |err| { - log.warn("failed to queue focus action for session {d}: {}", .{ clicked_session, err }); - }; + // Single click on a different pane moves the focus/selection + // highlight (no zoom, no spawn); double-click focuses (zooms) + // the pane. A single click on the already-focused pane is a + // no-op. Uses SDL's native click counter, which respects the + // OS double-click speed. + switch (gridClickOutcome(event.button.clicks, clicked_session, host.focused_session)) { + .none => {}, + .select => actions.append(.{ .SelectGridSession = clicked_session }) catch |err| { + log.warn("failed to queue select action for session {d}: {}", .{ clicked_session, err }); + }, + .focus => actions.append(.{ .FocusSession = clicked_session }) catch |err| { + log.warn("failed to queue focus action for session {d}: {}", .{ clicked_session, err }); + }, + } return true; } @@ -210,6 +222,18 @@ pub const SessionInteractionComponent = struct { } } + // Double-click in a focused pane returns to grid. We + // only reach here when the program is NOT capturing the + // mouse (the mouse-tracking branch above returned for + // those), so Claude/vim/etc. keep their own click + // behaviour; Cmd+Esc is the universal "back to grid". + if (event.button.button == c.SDL_BUTTON_LEFT and event.button.clicks == 2) { + actions.append(.RequestCollapseFocused) catch |err| { + log.warn("failed to queue collapse action: {}", .{err}); + }; + return true; + } + if (event.button.button == c.SDL_BUTTON_LEFT) { if (fullViewPinFromMouse(focused, view, mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows, host.ui_scale)) |pin| { const clicks = event.button.clicks; @@ -221,10 +245,10 @@ pub const SessionInteractionComponent = struct { const mod = c.SDL_GetModState(); const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; if (cmd_held) { - if (getLinkAtPin(self.allocator, &focused.terminal.?, pin, view.is_viewing_scrollback)) |uri| { + if (getLinkAtPin(self.allocator, &focused.terminal.?, pin, view.is_viewing_scrollback, focused.cwd_path)) |uri| { defer self.allocator.free(uri); - open_url.openUrl(self.allocator, uri) catch |err| { - log.err("failed to open URL: {}", .{err}); + open_url.openTarget(self.allocator, uri) catch |err| { + log.err("failed to open link: {}", .{err}); }; } else { beginSelection(focused, view, pin); @@ -353,7 +377,7 @@ pub const SessionInteractionComponent = struct { const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; if (cmd_held) { - if (getLinkMatchAtPin(self.allocator, &focused.terminal.?, pin.?, view.is_viewing_scrollback)) |link_match| { + if (getLinkMatchAtPin(self.allocator, &focused.terminal.?, pin.?, view.is_viewing_scrollback, focused.cwd_path)) |link_match| { desired_cursor = .pointer; view.hovered_link_start = link_match.start_pin; view.hovered_link_end = link_match.end_pin; @@ -652,6 +676,19 @@ pub const SessionInteractionComponent = struct { }; }; +const GridClickOutcome = enum { none, select, focus }; + +/// Decide what a left mouse-button-down in grid mode should do. A single click +/// on a pane other than the focused one moves the selection/focus highlight +/// (handled as `SelectGridSession`: no spawn, no zoom). A double-click focuses +/// (zooms) the pane (`FocusSession`). A single click on the already-focused +/// pane does nothing. +fn gridClickOutcome(clicks: u8, clicked_session: usize, focused_session: usize) GridClickOutcome { + if (clicks >= 2) return .focus; + if (clicked_session == focused_session) return .none; + return .select; +} + fn terminalHasMouseTracking(terminal: anytype) bool { return terminal.modes.get(.mouse_event_normal) or terminal.modes.get(.mouse_event_button) or @@ -910,7 +947,7 @@ const LinkMatch = struct { end_pin: ghostty_vt.Pin, }; -fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) ?LinkMatch { +fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool, cwd: ?[]const u8) ?LinkMatch { const page = &pin.node.data; const row_and_cell = pin.rowAndCell(); const cell = row_and_cell.cell; @@ -1032,11 +1069,27 @@ fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Termina if (click_cell_idx >= cell_to_byte.items.len) return null; const click_byte_pos = cell_to_byte.items[click_cell_idx]; - const url_match = url_matcher.findUrlMatchAtPosition(row_text, click_byte_pos) orelse return null; + // Match a scheme URL first (http://, file://, ...); if none, fall back to a + // bare filesystem path that exists relative to the pane's working directory. + var match_start: usize = undefined; + var match_end: usize = undefined; + var url_slice: ?[]const u8 = null; + var path_token: ?[]const u8 = null; + if (url_matcher.findUrlMatchAtPosition(row_text, click_byte_pos)) |m| { + match_start = m.start; + match_end = m.end; + url_slice = m.url; + } else if (path_matcher.findPathMatchAtPosition(row_text, click_byte_pos)) |m| { + match_start = m.start; + match_end = m.end; + path_token = m.path; + } else { + return null; + } var start_cell_idx: usize = 0; for (cell_to_byte.items, 0..) |byte, idx| { - if (byte >= url_match.start) { + if (byte >= match_start) { start_cell_idx = idx; break; } @@ -1044,7 +1097,7 @@ fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Termina var end_cell_idx: usize = cell_to_byte.items.len - 1; for (cell_to_byte.items, 0..) |byte, idx| { - if (byte >= url_match.end) { + if (byte >= match_end) { end_cell_idx = if (idx > 0) idx - 1 else 0; break; } @@ -1066,7 +1119,12 @@ fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Termina const link_start_pin = terminal.screens.active.pages.pin(link_start_point) orelse return null; const link_end_pin = terminal.screens.active.pages.pin(link_end_point) orelse return null; - const url = allocator.dupe(u8, url_match.url) catch return null; + // Build the owned target string. URLs are duped from the row text; bare + // paths are resolved to an absolute path and must exist on disk. + const url = if (url_slice) |s| + allocator.dupe(u8, s) catch return null + else + resolveExistingPath(allocator, cwd, path_token.?) orelse return null; return LinkMatch{ .url = url, @@ -1075,8 +1133,61 @@ fn getLinkMatchAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Termina }; } -fn getLinkAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool) ?[]u8 { - if (getLinkMatchAtPin(allocator, terminal, pin, is_viewing_scrollback)) |match| { +/// Resolve a path token against the pane's working directory and return an +/// owned absolute path IFF it exists on disk. The existence check is what keeps +/// arbitrary path-ish text from being treated as an openable link. Relative +/// tokens need `cwd`; `~` expands to $HOME. Caller owns/frees the result. +fn resolveExistingPath(allocator: std.mem.Allocator, cwd: ?[]const u8, token: []const u8) ?[]u8 { + if (token.len == 0) return null; + + const candidate: []u8 = if (token[0] == '/') + (allocator.dupe(u8, token) catch return null) + else if (token[0] == '~') blk: { + const home = std.posix.getenv("HOME") orelse return null; + if (token.len == 1) break :blk (allocator.dupe(u8, home) catch return null); + const rest = if (token[1] == '/') token[2..] else token[1..]; + if (rest.len == 0) break :blk (allocator.dupe(u8, home) catch return null); + break :blk std.fs.path.join(allocator, &.{ home, rest }) catch return null; + } else blk: { + const base = cwd orelse return null; + break :blk std.fs.path.join(allocator, &.{ base, token }) catch return null; + }; + + // Absolute candidate; cwd().access handles absolute paths without asserting. + std.fs.cwd().access(candidate, .{}) catch { + allocator.free(candidate); + return null; + }; + return candidate; +} + +test "resolveExistingPath - relative resolves against cwd; missing returns null" { + const allocator = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + try tmp.dir.writeFile(.{ .sub_path = "hello.txt", .data = "hi" }); + const dir_path = try tmp.dir.realpathAlloc(allocator, "."); + defer allocator.free(dir_path); + + // Existing relative path -> resolved absolute path. + const ok = resolveExistingPath(allocator, dir_path, "hello.txt") orelse return error.TestExpectedMatch; + defer allocator.free(ok); + try std.testing.expect(std.mem.endsWith(u8, ok, "hello.txt")); + + // Non-existent file -> null (the existence filter). + try std.testing.expect(resolveExistingPath(allocator, dir_path, "nope.txt") == null); + + // A relative token with no cwd cannot resolve. + try std.testing.expect(resolveExistingPath(allocator, null, "hello.txt") == null); + + // An absolute path that exists resolves even without a cwd. + const abs = resolveExistingPath(allocator, null, ok) orelse return error.TestExpectedMatch; + defer allocator.free(abs); + try std.testing.expectEqualStrings(ok, abs); +} + +fn getLinkAtPin(allocator: std.mem.Allocator, terminal: *ghostty_vt.Terminal, pin: ghostty_vt.Pin, is_viewing_scrollback: bool, cwd: ?[]const u8) ?[]u8 { + if (getLinkMatchAtPin(allocator, terminal, pin, is_viewing_scrollback, cwd)) |match| { return match.url; } return null; @@ -1232,6 +1343,22 @@ test "nav_wave_amplitude is smaller than wave_amplitude" { try testing.expect(nav_wave_amplitude < wave_amplitude); } +test "gridClickOutcome: single click selects a different pane, no-ops the focused one" { + // Single click on a different pane -> move the highlight (select). + try testing.expectEqual(GridClickOutcome.select, gridClickOutcome(1, 3, 0)); + // Single click on the already-focused pane -> nothing (no spawn, no zoom). + try testing.expectEqual(GridClickOutcome.none, gridClickOutcome(1, 2, 2)); +} + +test "gridClickOutcome: double-click focuses (zooms) regardless of current focus" { + // Double-click on a different pane -> zoom it. + try testing.expectEqual(GridClickOutcome.focus, gridClickOutcome(2, 3, 0)); + // Double-click on the already-focused pane -> still zoom (expand it). + try testing.expectEqual(GridClickOutcome.focus, gridClickOutcome(2, 1, 1)); + // Triple-click (clicks > 2) still resolves to focus. + try testing.expectEqual(GridClickOutcome.focus, gridClickOutcome(3, 4, 0)); +} + test "cellCodepoint honors content_tag for text and non-text cells" { const MockTag = enum { codepoint, codepoint_grapheme, bg_color_palette, bg_color_rgb }; const MockContent = union { diff --git a/src/ui/types.zig b/src/ui/types.zig index cfe98655..6a9bb409 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -45,6 +45,9 @@ pub const UiHost = struct { pub const UiAction = union(enum) { RestartSession: usize, FocusSession: usize, + /// Move the grid's focus/selection highlight to this pane without zooming + /// it to full screen or spawning a shell. Mirrors Cmd+Arrow grid nav. + SelectGridSession: usize, RequestCollapseFocused: void, ConfirmQuit: void, OpenConfig: void,