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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ const result = grid_row * GRID_COLS + grid_col; // usize, works correctly
- `std.process.Child` keeps only `kill(io)` and `wait(io)`. Creation is `std.process.spawn(io, opts)`; the collect-output pattern is `std.process.run(gpa, io, opts)`. `Child.Term` tags are lowercase (`.exited`, `.signal`, `.stopped`, `.unknown`) and `signal` carries `std.posix.SIG`, not `u32`. Use `src/proc.zig` for the application process helpers.
- Link and include configuration lives on `std.Build.Module`, not `std.Build.Step.Compile`. `link_libc` is a Module field, settable in `b.createModule`.
- `std.posix.getenv` is gone. Read the environment through `src/env.zig`.
- `std.fmt.bufPrintZ` is deprecated; use `std.fmt.bufPrintSentinel(buf, fmt, args, 0)`.
- Default `std.heap.DebugAllocator` initialization is deprecated; prefer `init.gpa` from `main` or the `.init` declaration when a local allocator is unavoidable.
- Tests use `std.testing.io` rather than constructing `std.Io.Threaded` instances.

### Inventory greps for std API migrations
`const fs = std.fs;` and `const posix = std.posix;` aliases hide call sites from a `std.`-qualified grep — this cost real time during the 0.16 migration. Always match `\b(std\.)?fs\.` and `\b(std\.)?posix\.`. When excluding survivors, put the underscore in the character class: `fs\.[A-Za-z]+` truncates `fs.max_path_bytes` to `fs.max` and lets it slip past the filter.
Expand Down
17 changes: 9 additions & 8 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## System Overview

Architect is a **single-process, layered desktop application** built in Zig that functions as a grid-based terminal multiplexer optimized for multi-agent AI coding workflows. It follows a five-layer architecture: a thin entrypoint delegates to an application runtime that owns the frame loop, platform abstraction (SDL3), session management (PTY + ghostty-vt terminal emulation), scene rendering, and a component-based UI overlay system. The UI and terminal loop run on the main thread; background threads are used only for bounded auxiliary work (notification socket listener, local control socket listener, a PTY reader thread that drains session output into per-session ring buffers at wire speed, and quit-time agent-teardown worker). The frame loop uses a wakeable wait model: both idle and active-frame pacing block in SDL until either a wake-worthy event arrives or the relevant deadline expires, so PTY output, keystrokes, notification/control socket activity, and window events all interrupt the wait immediately instead of waiting out a fixed timeout. The application uses an action-queue pattern for UI-to-app mutations, request queues for external socket inputs, epoch-based cache invalidation for efficient rendering, and a vtable-based component registry for extensible UI overlays. Renderer cache entries are reused for both grid tiles and the steady-state full-screen terminal view; overlays remain live unless an effect needs them baked into the cached texture.
Architect is a **single-process, layered desktop application** built in Zig that functions as a grid-based terminal multiplexer optimized for multi-agent AI coding workflows. It follows a five-layer architecture: a thin entrypoint delegates to an application runtime that owns the frame loop, platform abstraction (SDL3), session management (PTY + ghostty-vt terminal emulation), scene rendering, and a component-based UI overlay system. The UI and terminal loop run on the main thread; background threads are used only for bounded auxiliary work (notification socket listener, local control socket listener, a PTY reader thread that drains session output into per-session ring buffers at wire speed, a bounded URL opener worker set, and quit-time agent-teardown worker). The frame loop uses a wakeable wait model: both idle and active-frame pacing block in SDL until either a wake-worthy event arrives or the relevant deadline expires, so PTY output, keystrokes, notification/control socket activity, and window events all interrupt the wait immediately instead of waiting out a fixed timeout. The application uses an action-queue pattern for UI-to-app mutations, request queues for external socket inputs, epoch-based cache invalidation for efficient rendering, and a vtable-based component registry for extensible UI overlays. Renderer cache entries are reused for both grid tiles and the steady-state full-screen terminal view; overlays remain live unless an effect needs them baked into the cached texture.

## Component Diagram

Expand Down Expand Up @@ -91,8 +91,8 @@ Platform Session Rendering UI Overlay
**Invariants:**
- Session, Rendering, and UI Overlay layers never import from each other directly. All cross-layer communication flows through the Application layer or shared types.
- UI components communicate with the application exclusively via the `UiAction` queue (never direct state mutation).
- `main(init: std.process.Init)` passes `init.io` to `runtime.run(io, ...)`, which threads it through the application, session, and UI layers. I/O-owning structs store it beside their allocator; worker contexts copy it when their thread outlives the spawner.
- Background threads are intentionally limited to four cases: the notification socket listener (`session/notify.zig`), the local control socket listener (`app/control.zig`), the PTY reader (`session/pty_reader.zig`), and a quit-time agent-teardown worker in `app/runtime.zig`. They communicate completion/state back to the main thread through thread-safe primitives. The notification listener and control listener block in `poll(2)` on the listening socket plus a `WakePipe` self-pipe (`src/wake_pipe.zig`); shutdown stores the stop flag and signals the pipe, so no thread ever sleeps in a fixed-interval loop. The notification listener, control listener, and PTY reader also post a custom SDL wake event after queueing work or draining PTY bytes, so the frame loop breaks out of `SDL_WaitEventTimeout(...)` promptly during both idle and active-frame pacing. The PTY reader blocks in `poll(2)` on the master fds of all spawned sessions plus its `WakePipe`, which `register`/`retire` and shutdown signal; when one becomes readable, it drains it into that session's mutex-guarded ring buffer (`PtyOutputBuffer`, 1 MiB) — so producer processes are never backpressured by render pacing, and DEC-2026 sync windows close in the buffer as fast as the producer writes them. Sessions register their fd+buffer on spawn and retire it during teardown; reads happen only under the registry mutex, so `retire()` returning guarantees the reader can no longer touch the fd or buffer. The main thread's `processOutput` consumes from the buffer (VT parsing stays main-thread-only) and clears a shared `wake_pending` flag at the top of each frame; the reader posts at most one SDL wake event per frame via that flag.
- `main(init: std.process.Init)` passes `init.gpa` and `init.io` to `runtime.run(allocator, io, ...)`, which threads them through the application, session, and UI layers. I/O-owning structs store them together; worker contexts copy `io` when their thread outlives the spawner.
- Background threads are intentionally limited to five cases: the notification socket listener (`session/notify.zig`), the local control socket listener (`app/control.zig`), the PTY reader (`session/pty_reader.zig`), the bounded URL opener worker set (`os/open.zig`), and a quit-time agent-teardown worker in `app/runtime.zig`. The URL opener joins its active workers during runtime shutdown. The notification listener and control listener block in `poll(2)` on the listening socket plus a `WakePipe` self-pipe (`src/wake_pipe.zig`); shutdown stores the stop flag and signals the pipe, so no thread ever sleeps in a fixed-interval loop. The notification listener, control listener, and PTY reader also post a custom SDL wake event after queueing work or draining PTY bytes, so the frame loop breaks out of `SDL_WaitEventTimeout(...)` promptly during both idle and active-frame pacing. The PTY reader blocks in `poll(2)` on the master fds of all spawned sessions plus its `WakePipe`, which `register`/`retire` and shutdown signal; when one becomes readable, it drains it into that session's mutex-guarded ring buffer (`PtyOutputBuffer`, 1 MiB) — so producer processes are never backpressured by render pacing, and DEC-2026 sync windows close in the buffer as fast as the producer writes them. Sessions register their fd+buffer on spawn and retire it during teardown; reads happen only under the registry mutex, so `retire()` returning guarantees the reader can no longer touch the fd or buffer. The main thread's `processOutput` consumes from the buffer (VT parsing stays main-thread-only) and clears a shared `wake_pending` flag at the top of each frame; the reader posts at most one SDL wake event per frame via that flag.
- Shutdown order is UI-first for teardown dependencies: `UiRoot.deinit()` runs before session teardown so components that reference sessions are released while session memory is still valid.
- Runtime uses a one-shot teardown guard around UI cleanup so mixed `errdefer`/`defer` error unwind paths cannot deinitialize `UiRoot` twice.
- Runtime persistence is updated during the frame loop when runtime state changes (cwd changes, terminal spawn/despawn, window move/resize, font size changes), and finalization is explicit at the end of `app/runtime.zig`: final save and deinit `Persistence` before deferred subsystem teardown begins. Every change site only marks a dirty flag and records the time it first became dirty (`markPersistenceDirty`); the actual TOML write happens at most once per frame and only once the dirty state is at least 500ms old (`shouldSavePersistenceNow`), so a window drag or resize does not trigger a synchronous file write per mouse tick. The dirty timestamp is set once per dirty period (not refreshed by later changes), which caps the deferral at the debounce window even under continuous events.
Expand Down Expand Up @@ -139,10 +139,10 @@ These patterns are mandatory for all new code. They are derived from the archite

```
main(init: std.process.Init)
| init.io
| init.gpa, init.io
v
runtime.run(io, ...)
| explicit parameter or stored field
runtime.run(allocator, io, ...)
| explicit parameters or stored fields
+--> application and session layers
+--> UI components that perform I/O
+--> copied into worker-thread contexts
Expand Down Expand Up @@ -482,7 +482,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
|--------|---------------|----------------------------------|--------------|
| `main.zig` | Thin entrypoint + global logging hook registration | `main(init: std.process.Init)`, `std_options.logFn` | `app/runtime`, `logging` |
| `mcp/main.zig` | Separate `architect-mcp` stdio MCP server. Handles JSON-RPC lifecycle methods and exposes the single `spawn_session` tool. | `main(init: std.process.Init)`, `run()` | `app/control` module import, std |
| `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence, logging lifecycle/view-transition markers | `run(io, ...)`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, `logging`, all `app/*` modules |
| `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence, logging lifecycle/view-transition markers | `run(allocator, io, ...)`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, `logging`, all `app/*` modules |
| `app/frame_schedule.zig` | Pure change-driven frame scheduling policy: render demand classification, output cadence, timer deadlines, occlusion handling, and SDL wait timeout conversion | `Demand`, `Input`, `Schedule`, `schedule()`, `waitTimeoutMs()` | std |
| `app/control.zig` | Local control channel shared by the app and `architect-mcp`: spawn request schema, discovery file, Unix socket listener, request queue, and response serialization | `SpawnRequest`, `SpawnResponse`, `SpawnQueue`, `startControlThread()`, `connectAndSendSpawnRequest()` | std (socket, thread, JSON) |
| `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 |
Expand All @@ -499,6 +499,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
| `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) |
| `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 |
| `ui/root.zig` | UI component registry, z-index dispatch, action drain | `UiRoot`, `register()`, `handleEvent()`, `update()`, `render()`, `needsFrame()` | `ui/component`, `ui/types` |
| `ui/component.zig` | UI component vtable interface | `UiComponent`, `VTable` (handleEvent, update, render, hitTest, wantsFrame, deinit) | `ui/types`, `c` |
| `ui/types.zig` | Shared UI type definitions, including per-session attention state needed by grid chrome | `UiHost`, `UiAction`, `UiActionQueue`, `UiAssets`, `SessionUiInfo` | `app/app_state`, `colors`, `font`, `geom` |
Expand All @@ -521,7 +522,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
| `ui/components/pr_dropdown_fetch.zig` | `gh pr list` process execution, bounded diagnostic previews, ANSI normalization, and JSON parsing | `runGhPrList()`, `parseGhJson()` | `pr_dropdown_model`, std |
| `ui/components/pr_dropdown_view.zig` | Pull request pill and dropdown rendering, cached textures, label truncation/highlighting, and render-state projection | `Cache`, `RenderState`, `ensureCache()`, `renderGlyph()`, `renderOverlay()` | `pr_dropdown_model`, `flowing_line`, `search_utils`, `font_cache`, `geom`, `ui/types`, `ui/text_edit`, `colors`, `dpi`, `c` |
| `logging.zig` | File-backed structured logger with runtime level filtering and size-based rotation | `init()`, `deinit()`, `logFn()`, `writeEvent()`, `writeStartupMarker()`, `writeShutdownMarker()` | std |
| Shared Utilities (`geom`, `colors`, `dpi`, `config`, `logging`, `metrics`, `url_matcher`, `os/open`, `anim/easing`) | Geometry primitives, theme/palette management, DPI scaling helpers, TOML config loading/persistence, file-backed logging, performance metrics (including frame-loop and cache-refresh counters), URL detection, cross-platform URL opening, easing functions | `Rect`, `Theme`, `Config`, `logFn`, `Metrics`, `dpi.scale()`, `matchUrl()`, `open()`, `easeInOutCubic()`, `easeOutCubic()` | std, zig-toml, `c` |
| Shared Utilities (`geom`, `colors`, `dpi`, `config`, `logging`, `metrics`, `url_matcher`, `anim/easing`) | Geometry primitives, theme/palette management, DPI scaling helpers, TOML config loading/persistence, file-backed logging, performance metrics (including frame-loop and cache-refresh counters), URL detection, easing functions | `Rect`, `Theme`, `Config`, `logFn`, `Metrics`, `dpi.scale()`, `matchUrl()`, `easeInOutCubic()`, `easeOutCubic()` | std, zig-toml, `c` |

## Key Architectural Decisions

Expand Down
4 changes: 1 addition & 3 deletions src/app/control.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1030,9 +1030,7 @@ test "SpawnQueue drains queued requests" {

test "control thread stops promptly when signaled while blocked in poll" {
const allocator = std.testing.allocator;
var threaded: std.Io.Threaded = .init(allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const io = std.testing.io;

std.Io.Dir.cwd().createDirPath(io, ".tmp") catch |err| switch (err) {
error.PathAlreadyExists => {},
Expand Down
18 changes: 8 additions & 10 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1418,11 +1418,7 @@ fn startQuitFlow(
return false;
}

pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void {
var gpa = std.heap.DebugAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();

pub fn run(allocator: std.mem.Allocator, io: std.Io, log_dir_override: ?[]const u8) !void {
// Socket listener relays external "awaiting approval / done" signals from
// shells (or other tools) into the UI thread without blocking rendering.
var notify_queue = NotificationQueue{};
Expand Down Expand Up @@ -1599,6 +1595,8 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void {
pty_reader_wake.signal();
pty_reader_thread.join();
}
var opener = open_url.Opener.init(allocator, io);
defer opener.deinit();
var text_input_active = true;
var input_source_tracker = macos_input.InputSourceTracker.init();
defer input_source_tracker.deinit();
Expand Down Expand Up @@ -1797,7 +1795,7 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void {
pending_sends.deinit(allocator);
}

const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font);
const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, &opener, sessions, &font);
try ui.register(session_interaction_component.asComponent());

const worktree_comp_ptr = try allocator.create(ui_mod.worktree_overlay.WorktreeOverlayComponent);
Expand Down Expand Up @@ -1863,9 +1861,9 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void {
try ui.register(metrics_overlay_component.asComponent());
const diff_overlay_component = try ui_mod.diff_overlay.DiffOverlayComponent.init(allocator, io);
try ui.register(diff_overlay_component.asComponent());
const reader_overlay_component = try ui_mod.reader_overlay.ReaderOverlayComponent.init(allocator, sessions);
const reader_overlay_component = try ui_mod.reader_overlay.ReaderOverlayComponent.init(allocator, &opener, sessions);
try ui.register(reader_overlay_component.asComponent());
const story_overlay_component = try ui_mod.story_overlay.StoryOverlayComponent.init(allocator, io);
const story_overlay_component = try ui_mod.story_overlay.StoryOverlayComponent.init(allocator, io, &opener);
try ui.register(story_overlay_component.asComponent());
const selection_agent_overlay_component = try ui_mod.selection_agent_overlay.SelectionAgentOverlayComponent.init(allocator);
try ui.register(selection_agent_overlay_component.asComponent());
Expand Down Expand Up @@ -2995,11 +2993,11 @@ pub fn run(io: std.Io, log_dir_override: ?[]const u8) !void {
if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘,", now);

if (builtin.os.tag == .macos) {
_ = proc.spawnDetached(allocator, io, &.{ "open", "-t", config_path }) catch |err| {
_ = proc.spawnDetached(io, &.{ "open", "-t", config_path }) catch |err| {
std.debug.print("Failed to open config file: {}\n", .{err});
};
} else {
open_url.openUrl(allocator, config_path) catch |err| {
opener.open(config_path) catch |err| {
std.debug.print("Failed to open config file: {}\n", .{err});
};
}
Expand Down
Loading