Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .air/cloud/startup.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Provision Architect in AIR Cloud without Nix, apt, sudo, or root access.
# Zig supplies the C compiler used for the two SDL builds; all other tools are
# portable user-space archives. SDL 3.4.10 is parsed from the project's overlay
# portable user-space archives. SDL 3.4.18 is parsed from the project's overlay
# so this remains aligned with the normal development environment.
#
# This provisions disposable, non-production CI sandboxes rather than release
Expand Down Expand Up @@ -101,7 +101,7 @@ download_archive_bin just just-1.40.0.tar.gz "https://github.com/casey/just/rele
download_archive_bin shellcheck shellcheck-0.10.0.tar.xz "https://github.com/koalaman/shellcheck/releases/download/v0.10.0/shellcheck-v0.10.0.linux.x86_64.tar.xz" 0.10.0
download_archive_bin ruff ruff-0.12.10.tar.gz "https://github.com/astral-sh/ruff/releases/download/0.12.10/ruff-x86_64-unknown-linux-gnu.tar.gz" 0.12.10

sdl_version="$(sed -n 's/.*version = "\([0-9][0-9.]*\)".*/\1/p' "$project_dir/overlays/sdl3-3-4-10.nix" | head -1)"
sdl_version="$(sed -n 's/.*version = "\([0-9][0-9.]*\)".*/\1/p' "$project_dir/overlays/sdl3-3-4-18.nix" | head -1)"
[ -n "$sdl_version" ] || die "could not read SDL3 version from overlay"
sdl_ttf_version=3.2.2
build_sdl() {
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ If you are executing in a git worktree, stay within that worktree and do not att
### Never render while the window is occluded
- macOS stops compositing fully covered windows and `CAMetalLayer` stops returning drawables; any render attempt then blocks the main thread for the full ~1s `nextDrawable` timeout, stalling input and PTY processing. The frame loop gates rendering on the `SDL_WINDOW_OCCLUDED` window flag (`shouldRenderFrame` in `src/app/runtime.zig`); keep any new render/present paths behind the same gate.

### Mouse coordinates on macOS 26+
- SDL derives mouse motion, button, and wheel positions from its cached window position, which can go stale after a display reconfiguration and shift every mouse event by the difference between the displays' heights. `src/platform/sdl_mouse_workaround.zig` detects the stale cache and makes SDL recompute it (via its own `windowDidMove:`) before SDL generates more input events. Fix the cache rather than shifting event coordinates afterwards: some SDL paths already deliver correct window-local positions, and SDL drops or clamps events against the cached position before queuing them. The workaround is temporary: on every SDL bump, re-check it against SDL's Cocoa sources (the `verified_sdl_version` test fails until you do).

### Adding New SDL3 Key Codes
When adding references to SDL3 key codes (SDLK_*) or other SDL constants, always add them to `src/c.zig` first instead of searching the web for their values. The build system provides the translated SDL declarations through the `c_sdl` module, and SDL constants must be explicitly re-exported from `src/c.zig` to be accessible throughout the codebase.

Expand Down Expand Up @@ -245,6 +248,7 @@ The `<= len` pattern is only correct when `pos` represents a position *after* pr
- Do not store UI state or UI textures in session structs or `app_state.zig`; UI state must live inside UI components or UI-managed assets.
- Add new UI features by registering components with `UiRoot`; never bypass `UiAction` for UI→app mutations.
- When a UI component moves into a new visible state (modals expanding, toasts appearing, gesture indicators starting), use `src/ui/first_frame_guard.zig`: call `markTransition()` when the state flips and `markDrawn()` at the top of every render; have `wantsFrame` return `guard.wantsFrame()` so the main loop renders immediately even under idle throttling.
- Arm the guard when a component *leaves* a visible state too: an indicator or toast expiring, an expand/collapse settling (`ExpandingOverlay.settle`), an immediate close. Once `wantsFrame` turns false nothing repaints, so the last presented frame — drawn mid-fade or at an intermediate size — stays on screen until unrelated input arrives. A time-based expiry therefore needs an explicit state flip in `update()` that arms the guard; a `wantsFrame` that merely compares `now` against a deadline never gets that final frame.
- Restored terminals use `SessionState.ensureSpawnedWithDir` to launch shells already in the target directory; changing cwd of a live shell is not supported—spawn or restart with the desired path.

## Rendering & Unicode Notes
Expand All @@ -257,6 +261,7 @@ The `<= len` pattern is only correct when `pos` represents a position *after* pr
- Do not keep TOML parser-owned maps after `result.deinit()`: duplicate keys and values into your own storage before freeing the parser arena, or later iteration will segfault.
- `std.mem.span` rejects `[:0]const u8` slices; use `std.mem.sliceTo(ptr, 0)` for `[:0]const u8`, and use `std.mem.span` for `[*c]const u8`/`[*:0]const u8` C pointers.
- When copying persisted maps (e.g., `[terminals]`), duplicate both key and value slices; borrowing the parser's backing memory causes use-after-free crashes.
- Pointers from `std.HashMap` `getPtr`/`getOrPut` dangle after any later insert that grows the map. Do not return them from cache-style APIs; return the value (as `FontCache.get` does) or store values behind their own allocation.
- Terminal cwd persistence is currently macOS-only; other platforms skip saving/restoring terminals to avoid stale directories until cross-platform cwd tracking is implemented.
- xev process watchers keep a pointer to the provided userdata; if you reuse a shared struct for multiple spawns, a late callback can read updated fields and wrongly mark a new session dead. Allocate a per-watcher context, free it on teardown or after the callback, and bump a generation counter on spawn/despawn to ignore stale events.
- Restart buttons should only render when a session is both `spawned` and `dead`; broader checks can surface controls for never-spawned slots.
Expand Down
7 changes: 7 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,13 @@ fn addSdlPaths(
translate_c.addIncludePath(include_path);
const lib_path = b.fmt("{s}/../lib", .{sdl3_ttf_include});
exe_mod.addLibraryPath(.{ .cwd_relative = lib_path });
// sdl3.pc carries an rpath for SDL3, but sdl3-ttf.pc carries none, so an
// ELF binary linked against SDL3_ttf outside the default loader paths
// (e.g. in the Nix store) fails to start. Mach-O records each dylib's
// absolute install name instead and needs no rpath.
if (exe_mod.resolved_target.?.result.os.tag != .macos) {
exe_mod.addRPath(.{ .cwd_relative = lib_path });
}
}
if (framework_path) |path| {
const framework_lazy_path: std.Build.LazyPath = .{ .cwd_relative = path };
Expand Down
3 changes: 2 additions & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
| `app/terminal_history.zig` | Extract focused terminal scrollback + viewport text, strip ANSI escape sequences, convert OSC 133 prompt markers into reader-friendly prompt marker lines, and extract agent session IDs from PTY output for resumption | `extractSessionText()`, `extractTerminalText()`, `stripAnsiAlloc()`, `extractAgentSessionId()`, `buildResumeCommand()` | `session/state`, `ghostty-vt`, std |
| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard and submitted-paste construction, worktree commands (with configurable external directory and post-create init) | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `pasteText()`, `buildSubmittedPaste()`, `clearTerminal()`, `resolveWorktreeDir()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` |
| `platform/sdl.zig` | SDL3 initialization, window management, HiDPI | `init()`, `createWindow()`, `createRenderer()` | `c` |
| `platform/sdl_mouse_workaround.zig` | Temporary workaround for SDL on macOS 26+: re-syncs SDL's cached window position when it goes stale after a display reconfiguration, so mouse events land under the pointer. Remove once SDL fixes it upstream | `WindowPositionRepair`, `mayStaleWindowPosition()`, `verified_sdl_version` | `c`, AppKit/CoreGraphics via the Objective-C runtime |
| `input/mapper.zig` | SDL key adaptation, explicit compatibility bindings, shortcut detection; delegates protocol encoding to ghostty-vt | `encodeKeyWithMod()`, `KeyEncodingOptions`, shortcut helpers | `c`, `ghostty-vt` |
| `c.zig` | SDL3 and SDL3_ttf FFI re-exports from the build-system `c_sdl` module | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | `c_sdl`, SDL3 system libraries |
| `session/state.zig` | Terminal session lifecycle: PTY, ghostty-vt, process watcher, foreground agent detection, graceful agent teardown at quit, and main-thread ring-buffer consumption | `SessionState`, `AgentKind`, `init()`, `despawn()`, `deinit()`, `ensureSpawnedWithDir()`, `processOutput()`, `render_epoch`, `pending_write`, `detectForegroundAgent()`, `sendTermToForegroundPgrp()` | `shell`, `pty`, `pty_reader`, `vt_stream`, `cwd`, `font`, xev |
Expand All @@ -516,7 +517,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
| `wake_pipe.zig` | Non-blocking self-pipe used to wake blocking background-thread polls for PTY registry changes and shutdown | `WakePipe`, `poll_error_backoff_ns` | `posix_util`, std (poll) |
| `session/*` (shell, pty, vt_stream, cwd) | Shell spawning, PTY abstraction, VT parsing, working directory detection | `spawn()`, `Pty`, `VtStream.processBytes()`, `getCwd()` | std (posix), ghostty-vt |
| `render/renderer.zig` | Scene rendering: terminals, borders, animations, terminal scrollbar painting, first-launch onboarding hint. Exposes the shared focused-border primitive used by the cwd-bar UI component to preserve border z-order after the bar label | `render()`, `RenderCache`, `renderFocusBorder()`, per-session texture management | `font`, `font_cache`, `gfx/*`, `anim/easing`, `app/app_state`, `ui/components/scrollbar`, `c` |
| `font.zig` + `font_cache.zig` | Font rendering, HarfBuzz shaping, glyph LRU cache, shared font cache | `Font`, `openFont()`, `renderGlyph()`, `FontCache`, `getOrCreate()` | `font_paths`, `c` (SDL3_ttf) |
| `font.zig` + `font_cache.zig` | Font rendering, HarfBuzz shaping, glyph LRU cache, shared font cache | `Font`, `openFont()`, `renderGlyph()`, `FontCache`, `get()` (returns a `FontSet` by value) | `font_paths`, `c` (SDL3_ttf) |
| `gfx/*` (box_drawing, primitives) | Procedural box-drawing characters (U+2500-U+257F), rounded/thick border helpers, bezier arrow rendering | `renderBoxDrawing()`, `drawRoundedRect()`, `drawThickBorder()`, `fillRoundedRect()`, `renderBezierArrow()` | `c` |
| `env.zig`, `clock.zig`, `proc.zig` | Process-environment access, I/O-aware timestamps/sleep, and I/O-aware process execution helpers | `get()`, `now*()`, `sleepNanos()`, `run()`, `spawnDetached()` | std |
| `os/open.zig` | Bounded, joinable URL-opening worker owner | `Opener`, `open()`, `deinit()` | `proc`, std |
Expand Down
3 changes: 2 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ zig build run -- --log-dir .tmp/architect-debug-logs

- **ghostty-vt** is fetched as a pinned tarball via the Zig package manager (`build.zig.zon`).
- **Zwanzig v0.15.1** is pinned as a Zig build dependency and runs as a host-targeted `ReleaseFast` build tool through `zig build lint`. Architect passes its requested target architecture and operating system to Zwanzig for target-aware analysis.
- **SDL3** and **SDL3_ttf** are provided by Nix. SDL3 is pinned to 3.4.10 via `overlays/sdl3-3-4-10.nix` with binaries cached in the public `forketyfork` Cachix to avoid rebuilds.
- **SDL3** and **SDL3_ttf** are provided by Nix. SDL3 is pinned to 3.4.18 via `overlays/sdl3-3-4-18.nix` with binaries cached in the public `forketyfork` Cachix to avoid rebuilds.
When bumping SDL, re-check the workaround in `src/platform/sdl_mouse_workaround.zig` against the new SDL's Cocoa sources; its `verified_sdl_version` test fails until you either delete the workaround or bump the constant.

SDL3 and the platform C APIs are translated at build time with Zig's built-in
`addTranslateC` steps using the small header shims under `src/c/`. When SDL is
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
pkgs = import nixpkgs {
inherit system;
overlays = [
(import ./overlays/sdl3-3-4-10.nix)
(import ./overlays/sdl3-3-4-18.nix)
];
config = {
allowUnfree = true;
Expand Down
4 changes: 2 additions & 2 deletions overlays/sdl3-3-4-10.nix → overlays/sdl3-3-4-18.nix
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
final: prev: {
sdl3 = prev.sdl3.overrideAttrs (old: rec {
version = "3.4.10";
version = "3.4.18";

src = prev.fetchFromGitHub {
owner = "libsdl-org";
repo = "SDL";
rev = "release-${version}";
hash = "sha256-6Dph2eLiJUmpQzPWe8EuY5LrWhrFwde2f2dwfgCcWNw=";
hash = "sha256-3dP4FJ5OvZI6UPykJpE3hTH6kdW+Nn7gUSf3yiPCobo=";
};

# Drop nixpkgs' Linux-only zenity substitutions that no longer match SDL 3.4.x.
Expand Down
5 changes: 5 additions & 0 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const session_state = @import("../session/state.zig");
const view_state = @import("../ui/session_view_state.zig");
const platform = @import("../platform/sdl.zig");
const macos_input = @import("../platform/macos_input_source.zig");
const sdl_mouse_workaround = @import("../platform/sdl_mouse_workaround.zig");
const input = @import("../input/mapper.zig");
const renderer_mod = @import("../render/renderer.zig");
const pty_mod = @import("../pty.zig");
Expand Down Expand Up @@ -1606,6 +1607,8 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, log_dir_override: ?[]const
};
}

const window_position_repair = sdl_mouse_workaround.WindowPositionRepair.init(sdl.window);

const renderer = sdl.renderer;

var font_size: c_int = persistence.font_size;
Expand Down Expand Up @@ -1878,6 +1881,7 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, log_dir_override: ?[]const
var next_wait_timeout_ms: ?c_int = null;
var first_frame = true;
while (running) {
window_position_repair.check(sdl.window);
var next_event = waitForNextFrame(next_wait_timeout_ms);
const frame_start_ns: i128 = clock.nowNanos(io);
const now = clock.nowMillis(io);
Expand Down Expand Up @@ -1917,6 +1921,7 @@ pub fn run(allocator: std.mem.Allocator, io: std.Io, log_dir_override: ?[]const
c.SDL_EVENT_KEY_DOWN, c.SDL_EVENT_KEY_UP, c.SDL_EVENT_TEXT_INPUT, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_MOUSE_WHEEL, c.SDL_EVENT_MOUSE_MOTION => last_input_ns = frame_start_ns,
else => {},
}
if (sdl_mouse_workaround.mayStaleWindowPosition(event.type)) window_position_repair.check(sdl.window);
var scaled_event = layout.scaleEventToRender(&event, scale_x, scale_y);
if (builtin.os.tag == .macos and scaled_event.type == c.SDL_EVENT_KEY_DOWN) {
const key = scaled_event.key.key;
Expand Down
16 changes: 16 additions & 0 deletions src/c.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,28 @@ const c_import = @import("c_sdl");

pub const SDL_Init = c_import.SDL_Init;
pub const SDL_Quit = c_import.SDL_Quit;
pub const SDL_InitSubSystem = c_import.SDL_InitSubSystem;
pub const SDL_QuitSubSystem = c_import.SDL_QuitSubSystem;
pub const SDL_EnableScreenSaver = c_import.SDL_EnableScreenSaver;
pub const SDL_CreateWindow = c_import.SDL_CreateWindow;
pub const SDL_DestroyWindow = c_import.SDL_DestroyWindow;
pub const SDL_SetWindowPosition = c_import.SDL_SetWindowPosition;
pub const SDL_GetWindowSizeInPixels = c_import.SDL_GetWindowSizeInPixels;
pub const SDL_GetWindowSize = c_import.SDL_GetWindowSize;
pub const SDL_GetWindowPosition = c_import.SDL_GetWindowPosition;
pub const SDL_GetWindowProperties = c_import.SDL_GetWindowProperties;
pub const SDL_GetPointerProperty = c_import.SDL_GetPointerProperty;
pub const SDL_PROP_WINDOW_COCOA_WINDOW_POINTER = c_import.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER;
pub const SDL_SyncWindow = c_import.SDL_SyncWindow;
pub const SDL_WINDOW_HIDDEN = c_import.SDL_WINDOW_HIDDEN;
pub const SDL_GetVersion = c_import.SDL_GetVersion;
pub const SDL_GetWindowFlags = c_import.SDL_GetWindowFlags;
pub const SDL_WINDOW_OCCLUDED = c_import.SDL_WINDOW_OCCLUDED;
pub const SDL_WINDOW_INPUT_FOCUS = c_import.SDL_WINDOW_INPUT_FOCUS;
pub const SDL_GetWindowDisplayScale = c_import.SDL_GetWindowDisplayScale;
pub const SDL_WINDOW_HIGH_PIXEL_DENSITY = c_import.SDL_WINDOW_HIGH_PIXEL_DENSITY;
pub const SDL_CreateRenderer = c_import.SDL_CreateRenderer;
pub const SDL_CreateSoftwareRenderer = c_import.SDL_CreateSoftwareRenderer;
pub const SDL_GetRendererName = c_import.SDL_GetRendererName;
pub const SDL_DestroyRenderer = c_import.SDL_DestroyRenderer;
pub const SDL_SetRenderDrawColor = c_import.SDL_SetRenderDrawColor;
Expand Down Expand Up @@ -85,6 +95,12 @@ pub const SDL_EVENT_MOUSE_MOTION = c_import.SDL_EVENT_MOUSE_MOTION;
pub const SDL_EVENT_MOUSE_WHEEL = c_import.SDL_EVENT_MOUSE_WHEEL;
pub const SDL_EVENT_WINDOW_RESIZED = c_import.SDL_EVENT_WINDOW_RESIZED;
pub const SDL_EVENT_WINDOW_MOVED = c_import.SDL_EVENT_WINDOW_MOVED;
pub const SDL_EVENT_WINDOW_DISPLAY_CHANGED = c_import.SDL_EVENT_WINDOW_DISPLAY_CHANGED;
pub const SDL_EVENT_DISPLAY_FIRST = c_import.SDL_EVENT_DISPLAY_FIRST;
pub const SDL_EVENT_DISPLAY_LAST = c_import.SDL_EVENT_DISPLAY_LAST;
pub const SDL_EVENT_DISPLAY_ADDED = c_import.SDL_EVENT_DISPLAY_ADDED;
pub const SDL_EVENT_DISPLAY_REMOVED = c_import.SDL_EVENT_DISPLAY_REMOVED;
pub const SDL_EVENT_DISPLAY_MOVED = c_import.SDL_EVENT_DISPLAY_MOVED;
pub const SDL_EVENT_WINDOW_FOCUS_GAINED = c_import.SDL_EVENT_WINDOW_FOCUS_GAINED;
pub const SDL_EVENT_WINDOW_FOCUS_LOST = c_import.SDL_EVENT_WINDOW_FOCUS_LOST;
pub const SDL_EVENT_WINDOW_CLOSE_REQUESTED = c_import.SDL_EVENT_WINDOW_CLOSE_REQUESTED;
Expand Down
4 changes: 3 additions & 1 deletion src/font.zig
Original file line number Diff line number Diff line change
Expand Up @@ -689,8 +689,10 @@ pub const Font = struct {
/// texture at draw time. Non-emoji glyphs are rasterized in white once and
/// tinted per draw via color mod; emoji glyphs are already full-color, so
/// their mod is explicitly reset to white to avoid inheriting a stale tint
/// left on the texture object from a prior draw.
/// left on the texture object from a prior draw. Alpha mod is applied to
/// both so fading callers (e.g. the hotkey indicator) fade their text.
fn applyColorMod(texture: *c.SDL_Texture, fallback: Fallback, fg_color: c.SDL_Color) void {
_ = c.SDL_SetTextureAlphaMod(texture, fg_color.a);
if (fallback == .emoji) {
_ = c.SDL_SetTextureColorMod(texture, white.r, white.g, white.b);
} else {
Expand Down
Loading
Loading