From 8af32e5301351d37d2c43e98de183e49d155562c Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:43:43 -0400 Subject: [PATCH 01/10] feat(session): resume agent sessions across relaunch On quit, capture each pane's agent (Claude Code/Codex/Gemini) session id; on relaunch, reopen each session in place. Two parts make it reliable: - Live capture: a SessionStart hook reports the agent's session id over the notify socket (new agent_session notification + runtime dispatch), so ids are recorded as soon as a session starts instead of only scraped at quit. - Robust resume: the resume command is injected as ARCHITECT_RESUME_CMD and run once by the wrapper rc after the user's shell rc loads -- never typed into the PTY, so it cannot race startup or be eaten by a shell prompt. Also: a normal quit no longer wipes a captured id (teardown only overwrites on a successful scrape), and the focused/zoomed pane is persisted and restored. --- src/app/runtime.zig | 120 +++++++++++++++++++++++++---------- src/app/terminal_history.zig | 12 ++-- src/config.zig | 12 +++- src/session/notify.zig | 38 +++++++++++ src/session/state.zig | 11 ++++ src/shell.zig | 75 +++++++++++++++++++++- 6 files changed, 227 insertions(+), 41 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index e3adb126..8195d788 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, @@ -1431,29 +1451,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 +1484,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 }, @@ -2356,6 +2375,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 +2438,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 }); + }, } } @@ -3086,26 +3139,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/config.zig b/src/config.zig index 2acdf28b..ef8eacd4 100644 --- a/src/config.zig +++ b/src/config.zig @@ -326,6 +326,10 @@ 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, const TomlPersistenceV3 = struct { window: WindowConfig = .{}, @@ -334,6 +338,8 @@ 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, }; const TomlPersistenceV2 = struct { @@ -386,6 +392,8 @@ 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; if (result.value.terminals) |paths| { for (paths, 0..) |path, idx| { @@ -477,8 +485,10 @@ 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}); // Write terminal path and agent arrays before any sections if (self.terminal_entries.items.len > 0) { 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). From 4e4bd70375ec7f445753173f9dee1a159b2755b5 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 03:33:24 -0400 Subject: [PATCH 02/10] feat(input): grid keybindings, double-click focus, app-focus accent overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Shift+Arrow moves the grid selection (Cmd+Arrow still works). - Double-click a grid pane focuses it; a single click does nothing (Cmd+Return still focuses the selection). Typing — including Enter — goes to the selected grid pane as before. - Cmd+Esc returns a focused pane to the grid. The Escape-hold-to-collapse gesture is removed so single/double Esc pass straight through to the program (Claude Code's Esc-Esc rewind, vim's Esc). - Double-click a focused pane returns to grid (only when the program is not capturing the mouse). Accent / focus indicator: - When the window is NOT the frontmost app, an accent fill washes only the focused/selected pane ([ui] inactive_overlay_alpha, 0 disables) so you can still tell which pane is active. When the window is focused, that pane shows only its accent-coloured border — no fill. Other panes are unchanged. Fonts: - [font] size (focused) + [font] grid_scale (grid text multiplier). - Cmd+Shift+= / Cmd+- adjusts the focused size; Cmd+Opt+= / Cmd+Opt+- adjusts the grid scale (0.5-3.0). Both are persisted to persistence.toml and restored on the next launch. - [ui] inactive_overlay_alpha. Help overlay and config template updated; tests added for the new config fields, the Shift+Arrow / grid-font chords, and grid_font_scale persistence. --- src/app/runtime.zig | 85 ++++++++++++++++++++++- src/config.zig | 79 ++++++++++++++++++++- src/input/mapper.zig | 53 ++++++++++++++ src/render/renderer.zig | 43 ++++++++---- src/ui/components/help_overlay.zig | 10 +-- src/ui/components/session_interaction.zig | 23 +++++- 6 files changed, 271 insertions(+), 22 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 8195d788..263fd25b 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -1246,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| { @@ -1332,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; @@ -1552,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; @@ -1733,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); @@ -1742,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}); @@ -1842,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; @@ -2081,6 +2120,26 @@ pub fn run() !void { break :blk "Font size changed"; }; ui.showToast(notification_msg, now); + } else if (input.gridFontSizeShortcut(key, mod)) |direction| { + // Cmd+Opt +/- adjusts the grid-pane font scale and persists + // it (mirrors Cmd[+Shift] +/- for the focused/full size). + 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 (config.ui.show_hotkey_feedback) ui.showHotkey(if (direction == .increase) "⌘⌥+" else "⌘⌥-", now); + 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 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); @@ -2224,6 +2283,26 @@ pub fn run() !void { ui.showToast(notification_msg, now); std.debug.print("Switched to session via hotkey: {d}\n", .{idx}); } + } else if (input.gridSelectShortcut(key, mod)) |direction| { + // Shift+Arrow: move the grid selection. Pure navigation + // in Grid; passes through to the program elsewhere. + if (anim_state.mode == .Grid) { + if (config.ui.show_hotkey_feedback) { + const arrow = switch (direction) { + .up => "⇧↑", + .down => "⇧↓", + .left => "⇧←", + .right => "⇧→", + }; + ui.showHotkey(arrow, now); + } + try grid_nav.navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, false, grid.cols, grid.rows, &loop); + const new_session = anim_state.focused_session; + session_interaction_component.triggerNavWave(new_session, now); + } else if (focused.spawned and !focused.dead and !input_keys.isModifierKey(key)) { + session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); + try input_keys.handleKeyInput(focused, key, mod); + } } else if (input.gridNavShortcut(key, mod)) |direction| { if (anim_state.mode == .Grid) { if (config.ui.show_hotkey_feedback) { @@ -3098,6 +3177,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; diff --git a/src/config.zig b/src/config.zig index ef8eacd4..49253bd8 100644 --- a/src/config.zig +++ b/src/config.zig @@ -34,7 +34,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 +58,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 +79,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 { @@ -330,6 +342,9 @@ pub const Persistence = struct { /// 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 = .{}, @@ -340,6 +355,7 @@ pub const Persistence = struct { recent_folders: ?toml.HashMap(u32) = null, focused_session: usize = 0, zoomed: bool = false, + grid_font_scale: f32 = 0, }; const TomlPersistenceV2 = struct { @@ -394,6 +410,7 @@ pub const Persistence = struct { 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| { @@ -489,6 +506,7 @@ pub const Persistence = struct { 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) { @@ -827,10 +845,21 @@ pub const Config = struct { \\# Font options \\# [font] \\# family = "SFNSMono" + \\# size = 14 # focused/full-view font size (points); Cmd+Shift+= / Cmd+- at runtime + \\# grid_scale = 1.0 # grid-pane text size multiplier (0.5-3.0). Cmd+Opt+= / Cmd+Opt+- + \\# # adjusts it live and remembers it 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: Shift+Arrow or Cmd+Arrow + \\# 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 + \\# Focused font size: Cmd+Shift+= / Cmd+- (persisted) + \\# Grid font size: Cmd+Opt+= / Cmd+Opt+- (persisted) \\ \\# Rendering options \\# [rendering] @@ -840,6 +869,7 @@ 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] @@ -906,8 +936,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); @@ -1005,6 +1044,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; @@ -1190,6 +1264,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); @@ -1212,6 +1287,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| { @@ -1232,6 +1308,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/input/mapper.zig b/src/input/mapper.zig index 390c2831..64b8938f 100644 --- a/src/input/mapper.zig +++ b/src/input/mapper.zig @@ -7,6 +7,8 @@ pub const GridNavDirection = enum { up, down, left, right }; pub fn fontSizeShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?FontSizeDirection { if ((mod & c.SDL_KMOD_GUI) == 0) return null; + // Cmd+Opt +/- is reserved for the grid scale (gridFontSizeShortcut). + if ((mod & c.SDL_KMOD_ALT) != 0) return null; return switch (key) { c.SDLK_EQUALS, c.SDLK_KP_PLUS => if ((mod & c.SDL_KMOD_SHIFT) != 0) .increase else null, @@ -15,6 +17,20 @@ pub fn fontSizeShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?FontSizeDirectio }; } +/// Cmd+Option +/- adjusts the grid-pane font scale (distinct from Cmd[+Shift] +/// +/- which adjusts the focused/full font size). Requires Cmd+Alt; rejects +/// Ctrl. Accepts the '=' key with or without Shift for "increase". +pub fn gridFontSizeShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?FontSizeDirection { + if ((mod & c.SDL_KMOD_GUI) == 0) return null; + if ((mod & c.SDL_KMOD_ALT) == 0) return null; + if ((mod & c.SDL_KMOD_CTRL) != 0) return null; + return switch (key) { + c.SDLK_EQUALS, c.SDLK_KP_PLUS => .increase, + c.SDLK_MINUS, c.SDLK_KP_MINUS => .decrease, + else => null, + }; +} + pub fn gridNavShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?GridNavDirection { if ((mod & c.SDL_KMOD_GUI) == 0) return null; if ((mod & c.SDL_KMOD_SHIFT) != 0) return null; @@ -27,6 +43,22 @@ pub fn gridNavShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?GridNavDirection }; } +/// Shift+Arrow moves the grid selection. Distinct from gridNavShortcut +/// (Cmd+Arrow) so that Shift+Arrow can be a pure grid-navigation chord while +/// Cmd+Arrow keeps its dual role (grid nav + Full-mode panning). Requires +/// Shift and rejects Cmd/Ctrl/Alt so it never collides with other shortcuts. +pub fn gridSelectShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?GridNavDirection { + if ((mod & c.SDL_KMOD_SHIFT) == 0) return null; + if ((mod & (c.SDL_KMOD_GUI | c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT)) != 0) return null; + return switch (key) { + c.SDLK_UP => .up, + c.SDLK_DOWN => .down, + c.SDLK_LEFT => .left, + c.SDLK_RIGHT => .right, + else => null, + }; +} + pub fn canHandleEscapePress(mode: app_state.ViewMode) bool { return mode != .Grid and mode != .Collapsing and mode != .GridResizing; } @@ -364,6 +396,18 @@ test "encodeKeyWithMod - unknown key" { try std.testing.expectEqual(@as(usize, 0), n); } +test "gridSelectShortcut - shift+arrow only" { + try std.testing.expectEqual(GridNavDirection.up, gridSelectShortcut(c.SDLK_UP, c.SDL_KMOD_SHIFT).?); + try std.testing.expectEqual(GridNavDirection.right, gridSelectShortcut(c.SDLK_RIGHT, c.SDL_KMOD_SHIFT).?); + // No Shift -> not a select chord. + try std.testing.expect(gridSelectShortcut(c.SDLK_UP, 0) == null); + // Shift + Cmd/Ctrl/Alt -> rejected (avoids collisions). + try std.testing.expect(gridSelectShortcut(c.SDLK_UP, c.SDL_KMOD_SHIFT | c.SDL_KMOD_GUI) == null); + try std.testing.expect(gridSelectShortcut(c.SDLK_UP, c.SDL_KMOD_SHIFT | c.SDL_KMOD_CTRL) == null); + // Non-arrow -> null. + try std.testing.expect(gridSelectShortcut(c.SDLK_A, c.SDL_KMOD_SHIFT) == null); +} + test "fontSizeShortcut - plus/minus variants" { try std.testing.expectEqual(FontSizeDirection.increase, fontSizeShortcut(c.SDLK_EQUALS, c.SDL_KMOD_GUI | c.SDL_KMOD_SHIFT).?); try std.testing.expectEqual(FontSizeDirection.decrease, fontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI).?); @@ -372,6 +416,15 @@ test "fontSizeShortcut - plus/minus variants" { try std.testing.expect(fontSizeShortcut(c.SDLK_EQUALS, c.SDL_KMOD_SHIFT) == null); } +test "gridFontSizeShortcut - cmd+opt only; focused ignores opt" { + try std.testing.expectEqual(FontSizeDirection.increase, gridFontSizeShortcut(c.SDLK_EQUALS, c.SDL_KMOD_GUI | c.SDL_KMOD_ALT).?); + try std.testing.expectEqual(FontSizeDirection.decrease, gridFontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI | c.SDL_KMOD_ALT).?); + // Without Opt it is not a grid shortcut. + try std.testing.expect(gridFontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI) == null); + // The focused shortcut must NOT fire when Opt is held (no collision). + try std.testing.expect(fontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI | c.SDL_KMOD_ALT) == null); +} + test "encodeKeyWithMod - shift+tab legacy mode" { var buf: [16]u8 = undefined; const n = encodeKeyWithMod(c.SDLK_TAB, c.SDL_KMOD_SHIFT, false, false, &buf); diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 0d0f9388..c663e5c7 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); } } } diff --git a/src/ui/components/help_overlay.zig b/src/ui/components/help_overlay.zig index a74a7b81..d85369c5 100644 --- a/src/ui/components/help_overlay.zig +++ b/src/ui/components/help_overlay.zig @@ -11,16 +11,18 @@ 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 = "Double-click pane", .desc = "Focus it (full screen)" }, + .{ .key = "⌘ESC", .desc = "Collapse to grid view" }, + .{ .key = "⇧↑/↓/←/→", .desc = "Move grid selection" }, .{ .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 = "Focused font size" }, + .{ .key = "⌘⌥+ / ⌘⌥-", .desc = "Grid font size" }, .{ .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..b1cba683 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -180,9 +180,14 @@ 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 }); - }; + // A single click does nothing; only a double-click focuses + // (zooms) the pane. Uses SDL's native click counter, which + // respects the OS double-click speed. + if (event.button.clicks >= 2) { + actions.append(.{ .FocusSession = clicked_session }) catch |err| { + log.warn("failed to queue focus action for session {d}: {}", .{ clicked_session, err }); + }; + } return true; } @@ -210,6 +215,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; From e5e09e0f10c4e1cb3faee9fcc8e2de0b53657094 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:20:29 -0400 Subject: [PATCH 03/10] feat(terminal): Cmd+click / Cmd+hover to open bare file paths Extends the existing Cmd+click link opener (scheme URLs, OSC-8 hyperlinks) to bare filesystem paths printed in terminal output, e.g. the "src/foo.zig" paths Claude Code emits. - New path_matcher.zig: detects a path-like token under the cursor (absolute, ~-relative, or relative), stripping a :line[:col] suffix and trailing dots, and rejecting plain words. - Relative paths resolve against the pane's working directory; the path must exist on disk (the existence check keeps arbitrary text from matching). - Opens via the existing macOS `open` pipeline. Because it hooks the shared link matcher, Cmd+hover also underlines the path and shows the pointer cursor, exactly like URLs. Tests: path_matcher token detection + resolveExistingPath filesystem resolution. --- src/path_matcher.zig | 100 ++++++++++++++++++++++ src/ui/components/session_interaction.zig | 93 ++++++++++++++++++-- 2 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 src/path_matcher.zig 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/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index b1cba683..ec110773 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"); @@ -238,7 +239,7 @@ 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}); @@ -370,7 +371,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; @@ -927,7 +928,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; @@ -1049,11 +1050,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; } @@ -1061,7 +1078,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; } @@ -1083,7 +1100,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, @@ -1092,8 +1114,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; From 4c249d67d462cc8359a42b9f4f1fe663078153fd Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:57:34 -0400 Subject: [PATCH 04/10] feat(open): route clicked file paths to VS Code, HTML to the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a file path is Cmd+clicked in terminal output, open it in VS Code (`open -a "Visual Studio Code"`) instead of the default app — except .html/.htm files, which open with the default handler (the browser). Web URLs and file:// links keep going to the default handler. Refactors os/open.zig to a variable-length argv so it can pass the `-a ` form, and adds openTarget() used by the link click handler. --- src/os/open.zig | 83 +++++++++++++++-------- src/ui/components/session_interaction.zig | 4 +- 2 files changed, 56 insertions(+), 31 deletions(-) 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/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index ec110773..6f20ba94 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -241,8 +241,8 @@ pub const SessionInteractionComponent = struct { if (cmd_held) { 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); From 52c4d253e5bf8d7ee37ebbbaa41cb4a9f98cde4b Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:57:34 -0400 Subject: [PATCH 05/10] fix(font): independent, per-view grid and focus font sizes Cmd +/- is now context-aware: in grid view it changes the grid font size, in focus view it changes the focus font size. The two no longer bleed into each other (focus zoom compensates the grid scale so the grid holds steady), and both are persisted to persistence.toml and restored on relaunch. Replaces the separate Cmd+Opt grid shortcut (and gridFontSizeShortcut) with this single, more intuitive zoom-what-you-see control. --- src/app/runtime.zig | 88 ++++++++++++++++-------------- src/config.zig | 10 ++-- src/input/mapper.zig | 25 --------- src/ui/components/help_overlay.zig | 3 +- 4 files changed, 53 insertions(+), 73 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 263fd25b..5f304b7d 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -2095,51 +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; - - 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 }); + 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; - persistence.font_size = font_size; - persistence_dirty = true; - savePersistenceIfDirty(&persistence, allocator, &persistence_dirty); - } + 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 }); - 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 (input.gridFontSizeShortcut(key, mod)) |direction| { - // Cmd+Opt +/- adjusts the grid-pane font scale and persists - // it (mirrors Cmd[+Shift] +/- for the focused/full size). - 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 (config.ui.show_hotkey_feedback) ui.showHotkey(if (direction == .increase) "⌘⌥+" else "⌘⌥-", now); - 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); + 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 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 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); diff --git a/src/config.zig b/src/config.zig index 49253bd8..a6f3e19d 100644 --- a/src/config.zig +++ b/src/config.zig @@ -845,9 +845,10 @@ pub const Config = struct { \\# Font options \\# [font] \\# family = "SFNSMono" - \\# size = 14 # focused/full-view font size (points); Cmd+Shift+= / Cmd+- at runtime - \\# grid_scale = 1.0 # grid-pane text size multiplier (0.5-3.0). Cmd+Opt+= / Cmd+Opt+- - \\# # adjusts it live and remembers it across launches. + \\# 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] @@ -858,8 +859,7 @@ pub const Config = struct { \\# 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 - \\# Focused font size: Cmd+Shift+= / Cmd+- (persisted) - \\# Grid font size: Cmd+Opt+= / Cmd+Opt+- (persisted) + \\# Zoom a view: Cmd +/- (grid size in grid view, focus size in focus view; both persisted) \\ \\# Rendering options \\# [rendering] diff --git a/src/input/mapper.zig b/src/input/mapper.zig index 64b8938f..8701250e 100644 --- a/src/input/mapper.zig +++ b/src/input/mapper.zig @@ -7,8 +7,6 @@ pub const GridNavDirection = enum { up, down, left, right }; pub fn fontSizeShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?FontSizeDirection { if ((mod & c.SDL_KMOD_GUI) == 0) return null; - // Cmd+Opt +/- is reserved for the grid scale (gridFontSizeShortcut). - if ((mod & c.SDL_KMOD_ALT) != 0) return null; return switch (key) { c.SDLK_EQUALS, c.SDLK_KP_PLUS => if ((mod & c.SDL_KMOD_SHIFT) != 0) .increase else null, @@ -17,20 +15,6 @@ pub fn fontSizeShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?FontSizeDirectio }; } -/// Cmd+Option +/- adjusts the grid-pane font scale (distinct from Cmd[+Shift] -/// +/- which adjusts the focused/full font size). Requires Cmd+Alt; rejects -/// Ctrl. Accepts the '=' key with or without Shift for "increase". -pub fn gridFontSizeShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?FontSizeDirection { - if ((mod & c.SDL_KMOD_GUI) == 0) return null; - if ((mod & c.SDL_KMOD_ALT) == 0) return null; - if ((mod & c.SDL_KMOD_CTRL) != 0) return null; - return switch (key) { - c.SDLK_EQUALS, c.SDLK_KP_PLUS => .increase, - c.SDLK_MINUS, c.SDLK_KP_MINUS => .decrease, - else => null, - }; -} - pub fn gridNavShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?GridNavDirection { if ((mod & c.SDL_KMOD_GUI) == 0) return null; if ((mod & c.SDL_KMOD_SHIFT) != 0) return null; @@ -416,15 +400,6 @@ test "fontSizeShortcut - plus/minus variants" { try std.testing.expect(fontSizeShortcut(c.SDLK_EQUALS, c.SDL_KMOD_SHIFT) == null); } -test "gridFontSizeShortcut - cmd+opt only; focused ignores opt" { - try std.testing.expectEqual(FontSizeDirection.increase, gridFontSizeShortcut(c.SDLK_EQUALS, c.SDL_KMOD_GUI | c.SDL_KMOD_ALT).?); - try std.testing.expectEqual(FontSizeDirection.decrease, gridFontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI | c.SDL_KMOD_ALT).?); - // Without Opt it is not a grid shortcut. - try std.testing.expect(gridFontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI) == null); - // The focused shortcut must NOT fire when Opt is held (no collision). - try std.testing.expect(fontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI | c.SDL_KMOD_ALT) == null); -} - test "encodeKeyWithMod - shift+tab legacy mode" { var buf: [16]u8 = undefined; const n = encodeKeyWithMod(c.SDLK_TAB, c.SDL_KMOD_SHIFT, false, false, &buf); diff --git a/src/ui/components/help_overlay.zig b/src/ui/components/help_overlay.zig index d85369c5..bdd58392 100644 --- a/src/ui/components/help_overlay.zig +++ b/src/ui/components/help_overlay.zig @@ -21,8 +21,7 @@ const shortcuts = [_]Shortcut{ .{ .key = "⌘O", .desc = "Open recent folders" }, .{ .key = "⌘?", .desc = "Open help" }, .{ .key = "⌘N", .desc = "Spawn new terminal" }, - .{ .key = "⌘⇧+ / ⌘⇧-", .desc = "Focused font size" }, - .{ .key = "⌘⌥+ / ⌘⌥-", .desc = "Grid 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" }, From 347fe886ecff6f90aa01ac4ab300a40e387de24e Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 14:07:08 -0400 Subject: [PATCH 06/10] feat(input): single-click to focus grid panes; remove Shift+Arrow grid nav Single-click in grid view now moves the focus/selection highlight to the clicked pane without spawning a shell or zooming to full screen (double-click still expands to full screen). Routed via a new SelectGridSession UiAction so clicks never trigger FocusSession's spawn/zoom. Remove the Shift+Arrow grid-select chord; Cmd+Arrow remains the grid navigation control. Shift+Arrow now passes through to the focused terminal as a normal keystroke. Updates the help overlay, generated-config keybinding help, and README accordingly. --- README.md | 2 +- src/app/runtime.zig | 33 +++++++--------- src/config.zig | 2 +- src/input/mapper.zig | 28 -------------- src/ui/components/help_overlay.zig | 4 +- src/ui/components/session_interaction.zig | 47 ++++++++++++++++++++--- src/ui/types.zig | 3 ++ 7 files changed, 61 insertions(+), 58 deletions(-) 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/src/app/runtime.zig b/src/app/runtime.zig index 5f304b7d..dc975ef7 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -2289,26 +2289,6 @@ pub fn run() !void { ui.showToast(notification_msg, now); std.debug.print("Switched to session via hotkey: {d}\n", .{idx}); } - } else if (input.gridSelectShortcut(key, mod)) |direction| { - // Shift+Arrow: move the grid selection. Pure navigation - // in Grid; passes through to the program elsewhere. - if (anim_state.mode == .Grid) { - if (config.ui.show_hotkey_feedback) { - const arrow = switch (direction) { - .up => "⇧↑", - .down => "⇧↓", - .left => "⇧←", - .right => "⇧→", - }; - ui.showHotkey(arrow, now); - } - try grid_nav.navigateGrid(&anim_state, sessions, session_interaction_component, direction, now, true, false, grid.cols, grid.rows, &loop); - const new_session = anim_state.focused_session; - session_interaction_component.triggerNavWave(new_session, now); - } else if (focused.spawned and !focused.dead and !input_keys.isModifierKey(key)) { - session_interaction_component.resetScrollIfNeeded(anim_state.focused_session); - try input_keys.handleKeyInput(focused, key, mod); - } } else if (input.gridNavShortcut(key, mod)) |direction| { if (anim_state.mode == .Grid) { if (config.ui.show_hotkey_feedback) { @@ -2630,6 +2610,19 @@ pub fn run() !void { } std.debug.print("Expanding session: {d}\n", .{idx}); }, + .SelectGridSession => |idx| { + // Single click in grid view: move the focus/selection highlight + // to the clicked pane without spawning a shell or zooming to + // full screen. Mirrors the Cmd+Arrow grid-navigation path. + 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; + session_interaction_component.triggerNavWave(idx, now); + }, .DespawnSession => |idx| { if (idx < sessions.len) { if (anim_state.mode == .Full and anim_state.focused_session == idx) { diff --git a/src/config.zig b/src/config.zig index a6f3e19d..1f3d52aa 100644 --- a/src/config.zig +++ b/src/config.zig @@ -855,7 +855,7 @@ pub const Config = struct { \\# font_scale = 1.0 # legacy alias of [font] grid_scale \\ \\# Keybindings (not configurable): - \\# Move grid selection: Shift+Arrow or Cmd+Arrow + \\# 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 diff --git a/src/input/mapper.zig b/src/input/mapper.zig index 8701250e..390c2831 100644 --- a/src/input/mapper.zig +++ b/src/input/mapper.zig @@ -27,22 +27,6 @@ pub fn gridNavShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?GridNavDirection }; } -/// Shift+Arrow moves the grid selection. Distinct from gridNavShortcut -/// (Cmd+Arrow) so that Shift+Arrow can be a pure grid-navigation chord while -/// Cmd+Arrow keeps its dual role (grid nav + Full-mode panning). Requires -/// Shift and rejects Cmd/Ctrl/Alt so it never collides with other shortcuts. -pub fn gridSelectShortcut(key: c.SDL_Keycode, mod: c.SDL_Keymod) ?GridNavDirection { - if ((mod & c.SDL_KMOD_SHIFT) == 0) return null; - if ((mod & (c.SDL_KMOD_GUI | c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT)) != 0) return null; - return switch (key) { - c.SDLK_UP => .up, - c.SDLK_DOWN => .down, - c.SDLK_LEFT => .left, - c.SDLK_RIGHT => .right, - else => null, - }; -} - pub fn canHandleEscapePress(mode: app_state.ViewMode) bool { return mode != .Grid and mode != .Collapsing and mode != .GridResizing; } @@ -380,18 +364,6 @@ test "encodeKeyWithMod - unknown key" { try std.testing.expectEqual(@as(usize, 0), n); } -test "gridSelectShortcut - shift+arrow only" { - try std.testing.expectEqual(GridNavDirection.up, gridSelectShortcut(c.SDLK_UP, c.SDL_KMOD_SHIFT).?); - try std.testing.expectEqual(GridNavDirection.right, gridSelectShortcut(c.SDLK_RIGHT, c.SDL_KMOD_SHIFT).?); - // No Shift -> not a select chord. - try std.testing.expect(gridSelectShortcut(c.SDLK_UP, 0) == null); - // Shift + Cmd/Ctrl/Alt -> rejected (avoids collisions). - try std.testing.expect(gridSelectShortcut(c.SDLK_UP, c.SDL_KMOD_SHIFT | c.SDL_KMOD_GUI) == null); - try std.testing.expect(gridSelectShortcut(c.SDLK_UP, c.SDL_KMOD_SHIFT | c.SDL_KMOD_CTRL) == null); - // Non-arrow -> null. - try std.testing.expect(gridSelectShortcut(c.SDLK_A, c.SDL_KMOD_SHIFT) == null); -} - test "fontSizeShortcut - plus/minus variants" { try std.testing.expectEqual(FontSizeDirection.increase, fontSizeShortcut(c.SDLK_EQUALS, c.SDL_KMOD_GUI | c.SDL_KMOD_SHIFT).?); try std.testing.expectEqual(FontSizeDirection.decrease, fontSizeShortcut(c.SDLK_MINUS, c.SDL_KMOD_GUI).?); diff --git a/src/ui/components/help_overlay.zig b/src/ui/components/help_overlay.zig index bdd58392..3ef87d00 100644 --- a/src/ui/components/help_overlay.zig +++ b/src/ui/components/help_overlay.zig @@ -11,9 +11,9 @@ const ExpandingOverlay = @import("expanding_overlay.zig").ExpandingOverlay; const Shortcut = struct { key: []const u8, desc: []const u8 }; const shortcuts = [_]Shortcut{ - .{ .key = "Double-click pane", .desc = "Focus it (full screen)" }, + .{ .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 = "Move grid selection" }, .{ .key = "⌘↑/↓/←/→", .desc = "Navigate grid" }, .{ .key = "⌘1–⌘9/⌘0", .desc = "Jump to a grid slot" }, .{ .key = "⌘↵", .desc = "Focus selected pane" }, diff --git a/src/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index 6f20ba94..4b16d739 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -181,13 +181,19 @@ 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; - // A single click does nothing; only a double-click focuses - // (zooms) the pane. Uses SDL's native click counter, which - // respects the OS double-click speed. - if (event.button.clicks >= 2) { - actions.append(.{ .FocusSession = clicked_session }) catch |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; } @@ -670,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 @@ -1324,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, From d61ee6b7bac2cb08ec34454583c2e03399338953 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:43:19 -0400 Subject: [PATCH 07/10] feat(ui): instant click-to-focus, remove grid veil, Cacha-matched background - Single-click in grid view now focuses a pane instantly with no animation; the moved accent border is the feedback. The nav-wave stays on keyboard navigation only, matching the standard pointer-vs-keyboard focus convention (:focus-visible) and keeping clicks snappy. - Remove the translucent background veil that dimmed grid-view panes. - Default terminal background is now #262624 to match the Cacha dark theme. --- docs/configuration.md | 4 ++-- src/app/runtime.zig | 10 ++++++---- src/colors.zig | 8 ++++---- src/config.zig | 11 ++++++----- src/render/renderer.zig | 4 ---- 5 files changed, 18 insertions(+), 19 deletions(-) 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/src/app/runtime.zig b/src/app/runtime.zig index dc975ef7..378698cc 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -2611,9 +2611,12 @@ pub fn run() !void { std.debug.print("Expanding session: {d}\n", .{idx}); }, .SelectGridSession => |idx| { - // Single click in grid view: move the focus/selection highlight - // to the clicked pane without spawning a shell or zooming to - // full screen. Mirrors the Cmd+Arrow grid-navigation path. + // 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; @@ -2621,7 +2624,6 @@ pub fn run() !void { session_interaction_component.clearSelection(anim_state.focused_session); session_interaction_component.clearSelection(idx); anim_state.focused_session = idx; - session_interaction_component.triggerNavWave(idx, now); }, .DespawnSession => |idx| { if (idx < sessions.len) { 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 1f3d52aa..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 }; @@ -873,7 +874,7 @@ pub const Config = struct { \\ \\# Theme colors (hex format) \\# [theme] - \\# background = "#0E1116" + \\# background = "#262624" \\# foreground = "#CDD6E0" \\# selection = "#1B2230" \\# accent = "#61AFEF" @@ -1017,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); diff --git a/src/render/renderer.zig b/src/render/renderer.zig index c663e5c7..89565b66 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -1144,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 }; From 4dc1e2a1d75dbb6749101b86c7154a00a8c2d3c1 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:43:20 -0400 Subject: [PATCH 08/10] chore(dev): add dev-reload script and /reload-architect skill Rebuild from the current worktree and quit/relaunch the installed app in one step, so source edits never leave a stale binary running. Defaults to a ReleaseFast build (--debug opt-in), sets up the native Homebrew SDL3 + macOS 15.4 SDK workaround so it builds without Nix, and waits for a clean shutdown before relaunching. Exposed as 'just reload' and a Claude Code skill. --- .claude/skills/reload-architect/SKILL.md | 42 +++++++ docs/development.md | 12 ++ justfile | 4 + scripts/dev-reload.sh | 136 +++++++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 .claude/skills/reload-architect/SKILL.md create mode 100755 scripts/dev-reload.sh 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/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..053050d3 --- /dev/null +++ b/scripts/dev-reload.sh @@ -0,0 +1,136 @@ +#!/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 the running Architect and relaunching the fresh build..." +# Variables below are for the inner shell, not this one (intentional single quotes). +# shellcheck disable=SC2016 +nohup bash -c ' + set -e + staging="$1" + # Graceful quit first so Architect saves state (terminals restore on relaunch) + # and tears down agents. Give it up to ~30s — a busy app can be slow here. + osascript -e "quit app \"Architect\"" 2>/dev/null || true + tries=0 + while pgrep -x architect >/dev/null 2>&1 && [ "$tries" -lt 150 ]; do + sleep 0.2 + tries=$((tries + 1)) + done + # Escalate if it is still alive, and crucially WAIT until it is truly gone + # before launching — otherwise `open` just re-activates the dying instance. + if pgrep -x architect >/dev/null 2>&1; then + pkill -x architect 2>/dev/null || true + tries=0 + while pgrep -x architect >/dev/null 2>&1 && [ "$tries" -lt 100 ]; do + sleep 0.2 + tries=$((tries + 1)) + done + pkill -9 -x architect 2>/dev/null || true + sleep 0.5 + fi + rm -rf "/Applications/Architect.app" + mv "$staging/Architect.app" "/Applications/Architect.app" + rmdir "$staging" 2>/dev/null || true + open "/Applications/Architect.app" +' _ "$staging" >/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." From c587791be3207bd730f3cb0a43b0dbbdb2844db9 Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:14:42 -0400 Subject: [PATCH 09/10] fix(dev-reload): wait for graceful agent teardown instead of force-killing The reload force-killed Architect after 30s. With busy/looping agents, that interrupted its quit teardown before each Claude could flush its session to disk, so the next 'claude --resume' reloaded a rewound transcript (lost completed turns). Now wait for Architect to exit on its own (re-sending the quit periodically for a saturated app), and only force-kill as an absolute last resort after ~5 minutes. --- scripts/dev-reload.sh | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/scripts/dev-reload.sh b/scripts/dev-reload.sh index 053050d3..16dea0dd 100755 --- a/scripts/dev-reload.sh +++ b/scripts/dev-reload.sh @@ -97,31 +97,36 @@ 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 the running Architect and relaunching the fresh build..." +echo "==> Quitting Architect (waiting for agents to shut down + flush) and relaunching..." # Variables below are for the inner shell, not this one (intentional single quotes). # shellcheck disable=SC2016 nohup bash -c ' set -e staging="$1" - # Graceful quit first so Architect saves state (terminals restore on relaunch) - # and tears down agents. Give it up to ~30s — a busy app can be slow here. + # Quit gracefully and WAIT for Architect to exit on its own. This is critical + # for the agent-resume feature: on quit, Architect Ctrl+Cs each running + # agent so Claude flushes its session to disk before exiting. Force-killing + # here kills the agents abruptly mid-turn — their most recent turns never + # reach disk, so the next `claude --resume` reloads a rewound transcript. + # So do NOT force-kill on a short timeout; give the teardown all the time it + # needs (busy/looping agents can take tens of seconds). Re-send the quit + # periodically in case a saturated app missed the first one. Only escalate as + # an absolute last resort after ~5 minutes, which means the app truly hung. osascript -e "quit app \"Architect\"" 2>/dev/null || true - tries=0 - while pgrep -x architect >/dev/null 2>&1 && [ "$tries" -lt 150 ]; do - sleep 0.2 - tries=$((tries + 1)) + waited=0 + while pgrep -x architect >/dev/null 2>&1 && [ "$waited" -lt 1200 ]; do + sleep 0.25 + waited=$((waited + 1)) + if [ $((waited % 120)) -eq 0 ]; then + osascript -e "quit app \"Architect\"" 2>/dev/null || true + fi done - # Escalate if it is still alive, and crucially WAIT until it is truly gone - # before launching — otherwise `open` just re-activates the dying instance. if pgrep -x architect >/dev/null 2>&1; then + # Teardown never finished after 5 min — last resort, this loses agent state. pkill -x architect 2>/dev/null || true - tries=0 - while pgrep -x architect >/dev/null 2>&1 && [ "$tries" -lt 100 ]; do - sleep 0.2 - tries=$((tries + 1)) - done + sleep 2 pkill -9 -x architect 2>/dev/null || true - sleep 0.5 + sleep 1 fi rm -rf "/Applications/Architect.app" mv "$staging/Architect.app" "/Applications/Architect.app" From a1c5d86f264b99e9862189ab0703b059bc52198a Mon Sep 17 00:00:00 2001 From: roba <10408936+roba-adnew@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:23:49 -0400 Subject: [PATCH 10/10] fix(dev-reload): wait on process-exit event instead of polling + re-sending quit Replace the poll loop + periodic quit re-send with a single graceful quit followed by 'caffeinate -w ', which blocks on the process-exit event (no busy-poll, no re-sending). A backgrounded watchdog force-kills only if the teardown truly hangs (~5 min). The re-send was an unnecessary defensive hack; the earlier missed-teardown was the old 30s force-kill, not a dropped quit. --- scripts/dev-reload.sh | 40 ++++++++++++++++------------------------ 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/scripts/dev-reload.sh b/scripts/dev-reload.sh index 16dea0dd..ddd7cb7a 100755 --- a/scripts/dev-reload.sh +++ b/scripts/dev-reload.sh @@ -98,41 +98,33 @@ 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" - # Quit gracefully and WAIT for Architect to exit on its own. This is critical - # for the agent-resume feature: on quit, Architect Ctrl+Cs each running - # agent so Claude flushes its session to disk before exiting. Force-killing - # here kills the agents abruptly mid-turn — their most recent turns never - # reach disk, so the next `claude --resume` reloads a rewound transcript. - # So do NOT force-kill on a short timeout; give the teardown all the time it - # needs (busy/looping agents can take tens of seconds). Re-send the quit - # periodically in case a saturated app missed the first one. Only escalate as - # an absolute last resort after ~5 minutes, which means the app truly hung. + 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 - waited=0 - while pgrep -x architect >/dev/null 2>&1 && [ "$waited" -lt 1200 ]; do - sleep 0.25 - waited=$((waited + 1)) - if [ $((waited % 120)) -eq 0 ]; then - osascript -e "quit app \"Architect\"" 2>/dev/null || true - fi - done - if pgrep -x architect >/dev/null 2>&1; then - # Teardown never finished after 5 min — last resort, this loses agent state. - pkill -x architect 2>/dev/null || true - sleep 2 - pkill -9 -x architect 2>/dev/null || true - sleep 1 + 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" >/dev/null 2>&1 & +' _ "$staging" "$old_pid" >/dev/null 2>&1 & disown # The detached job owns the staging dir now; do not let our EXIT trap delete it.