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: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ If you are executing in a git worktree, stay within that worktree and do not att
- 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.

### 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. SDL3 constants are exposed through the c_import and must be explicitly re-exported in c.zig to be accessible throughout the codebase.
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.

**Pattern:**
```zig
Expand Down Expand Up @@ -181,6 +181,7 @@ const result = grid_row * GRID_COLS + grid_col; // usize, works correctly
- When hoisting shared locals (e.g., `cursor`) to wider scopes inside long functions, avoid re-declaring them later with the same name. Zig treats this as shadowing and fails compilation. Prefer a single binding per logical value or choose distinct names for nested scopes to prevent "local constant shadows" errors.

### Zig 0.16 API notes
- `@cImport` is deprecated in Zig 0.16. C headers are translated through `addTranslateC` modules backed by the shims under `src/c/`.
- `std.ArrayList(T)` is the unmanaged list: init with `.empty` (or `initCapacity`), and pass the allocator to each method (`list.append(allocator, item)`). `std.ArrayListUnmanaged` is a deprecated alias — do not use it.
- `std.fs` retains only `path`, `max_path_bytes`, `max_name_bytes`, and the base64 alphabets. Every filesystem operation moved to `std.Io.Dir` / `std.Io.File` and takes an `io` argument. Two irregularities to remember: `Dir.renameAbsolute(old, new, io)` takes `io` **last**, and `makeDirAbsolute`/`makePath` were renamed to `createDirAbsolute`/`createDirPath` rather than merely gaining a parameter.
- `std.time` retains only its duration constants. Timestamps come from `clock.zig`, which wraps `std.Io.Timestamp.now(io, .real)`. The clock enum tags are `real`, `awake`, `boot`, `cpu_process`, `cpu_thread`.
Expand Down
82 changes: 66 additions & 16 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ pub fn build(b: *std.Build) void {
});
exe_mod.addImport("assets", assets_mod);

const c_sdl = addTranslateCModule(b, exe_mod, "c_sdl", "src/c/sdl.h", target, optimize);
const pty_header = switch (target.result.os.tag) {
.macos => "src/c/pty_macos.h",
.freebsd => "src/c/pty_freebsd.h",
else => "src/c/pty_linux.h",
};
_ = addTranslateCModule(b, exe_mod, "c_pty", pty_header, target, optimize);
_ = addTranslateCModule(b, exe_mod, "c_stdlib", "src/c/libc_stdlib.h", target, optimize);
_ = addTranslateCModule(b, exe_mod, "c_time", "src/c/libc_time.h", target, optimize);

if (target.result.os.tag == .macos) {
_ = addTranslateCModule(b, exe_mod, "c_libproc", "src/c/libproc.h", target, optimize);
_ = addTranslateCModule(b, exe_mod, "c_sysctl", "src/c/sysctl.h", target, optimize);
}

if (b.lazyDependency("ghostty", .{
.target = target,
.optimize = optimize,
Expand Down Expand Up @@ -98,6 +113,12 @@ pub fn build(b: *std.Build) void {
exe_mod.linkSystemLibrary("SDL3", .{});
exe_mod.linkSystemLibrary("SDL3_ttf", .{});

const framework_path: ?[]const u8 = if (target.result.os.tag == .macos)
if (findSdkRoot(b)) |sdk_root| b.fmt("{s}/System/Library/Frameworks", .{sdk_root}) else null
else
null;
addSdlPaths(b, exe_mod, c_sdl, framework_path);

if (target.result.os.tag == .macos) {
exe.headerpad_max_install_names = true;
mcp_exe.headerpad_max_install_names = true;
Expand All @@ -106,22 +127,6 @@ pub fn build(b: *std.Build) void {
exe_mod.linkFramework("Carbon", .{});
exe_mod.linkFramework("CoreFoundation", .{});
exe_mod.linkFramework("AppKit", .{});

if (findSdkRoot(b)) |sdk_root| {
const framework_path = b.fmt("{s}/System/Library/Frameworks", .{sdk_root});
exe_mod.addFrameworkPath(.{ .cwd_relative = framework_path });
}
}

if (b.graph.environ_map.get("SDL3_INCLUDE_PATH")) |sdl3_include| {
exe_mod.addIncludePath(.{ .cwd_relative = sdl3_include });
const lib_path = b.fmt("{s}/../lib", .{sdl3_include});
exe_mod.addLibraryPath(.{ .cwd_relative = lib_path });
}
if (b.graph.environ_map.get("SDL3_TTF_INCLUDE_PATH")) |sdl3_ttf_include| {
exe_mod.addIncludePath(.{ .cwd_relative = sdl3_ttf_include });
const ttf_lib_path = b.fmt("{s}/../lib", .{sdl3_ttf_include});
exe_mod.addLibraryPath(.{ .cwd_relative = ttf_lib_path });
}

b.installArtifact(exe);
Expand Down Expand Up @@ -169,6 +174,51 @@ pub fn build(b: *std.Build) void {
lint_step.dependOn(&run_zwanzig.step);
}

fn addTranslateCModule(
b: *std.Build,
exe_mod: *std.Build.Module,
name: []const u8,
header: []const u8,
target: std.Build.ResolvedTarget,
optimize: std.builtin.OptimizeMode,
) *std.Build.Step.TranslateC {
const translate_c = b.addTranslateC(.{
.root_source_file = b.path(header),
.target = target,
.optimize = optimize,
.link_libc = true,
});
exe_mod.addImport(name, translate_c.createModule());
return translate_c;
}

fn addSdlPaths(
b: *std.Build,
exe_mod: *std.Build.Module,
translate_c: *std.Build.Step.TranslateC,
framework_path: ?[]const u8,
) void {
if (b.graph.environ_map.get("SDL3_INCLUDE_PATH")) |sdl3_include| {
const include_path: std.Build.LazyPath = .{ .cwd_relative = sdl3_include };
exe_mod.addIncludePath(include_path);
translate_c.addIncludePath(include_path);
const lib_path = b.fmt("{s}/../lib", .{sdl3_include});
exe_mod.addLibraryPath(.{ .cwd_relative = lib_path });
}
if (b.graph.environ_map.get("SDL3_TTF_INCLUDE_PATH")) |sdl3_ttf_include| {
const include_path: std.Build.LazyPath = .{ .cwd_relative = sdl3_ttf_include };
exe_mod.addIncludePath(include_path);
translate_c.addIncludePath(include_path);
const lib_path = b.fmt("{s}/../lib", .{sdl3_ttf_include});
exe_mod.addLibraryPath(.{ .cwd_relative = lib_path });
}
if (framework_path) |path| {
const framework_lazy_path: std.Build.LazyPath = .{ .cwd_relative = path };
exe_mod.addFrameworkPath(framework_lazy_path);
translate_c.addFrameworkPath(framework_lazy_path);
}
}

// Prefer the active developer selection over hardcoded SDK locations so
// macOS SDK overrides in the dev shell stay local to the environment.
fn findSdkRoot(b: *std.Build) ?[]const u8 {
Expand Down
16 changes: 13 additions & 3 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ graph TD
subgraph Platform Layer
SDL["platform/sdl.zig<br/><i>SDL3 window, renderer, HiDPI</i>"]
IM["input/mapper.zig<br/><i>Keycodes to VT sequences</i>"]
CZIG["c.zig<br/><i>C FFI re-exports</i>"]
CZIG["c.zig<br/><i>SDL C FFI re-exports from c_sdl</i>"]
end

subgraph Session Layer
Expand Down Expand Up @@ -489,7 +489,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
| `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` |
| `input/mapper.zig` | SDL keycodes to VT escape sequences, shortcut detection | `encodeKey()`, modifier helpers | `c` |
| `c.zig` | C FFI re-exports (SDL3, SDL3_ttf constants) | `SDLK_*`, `SDL_*`, `TTF_*` re-exports | SDL3 system libs (via `@cImport`) |
| `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 |
| `session/notify.zig` | Background notification socket thread and queue; handles status and story notifications | `NotificationQueue`, `Notification` (union: status/story), `startThread()`, `push()`, `drain()` | std (socket, thread) |
| `session/pty_reader.zig` | Background thread that `poll(2)`s spawned sessions' PTY master fds and drains readable ones into per-session SPSC ring buffers; registry with retire handshake so teardown can safely close fds | `PtyReader`, `PtyOutputBuffer`, `start()`, `register()`, `retire()` | std (poll, thread) |
Expand Down Expand Up @@ -574,7 +574,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
### ADR-006: SDL3 for Rendering and Input

- **Decision:** Use SDL3 as the platform abstraction layer for window management, GPU-accelerated 2D rendering, input events, and font rendering (via SDL3_ttf with HarfBuzz).
- **Context:** The application needs cross-platform window management, hardware-accelerated texture rendering, and HiDPI support. SDL3 provides all of these with a C API that Zig can import directly via `@cImport`.
- **Context:** The application needs cross-platform window management, hardware-accelerated texture rendering, and HiDPI support. SDL3 provides all of these with a C API whose declarations are translated at build time and re-exported through `c.zig`.
- **Alternatives considered:**
- *Native platform APIs (AppKit/Metal)* -- rejected because it locks the project to macOS; SDL3 allows future Linux/Windows porting.
- *Vulkan/OpenGL directly* -- rejected because 2D terminal rendering does not need low-level GPU control, and SDL3's renderer API is sufficient and simpler.
Expand Down Expand Up @@ -681,3 +681,13 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
- Process-exit detection can lag by up to 1 s (the idle ceiling).
- The output cadence cap is a constant (`output_render_interval_ns`) rather than config.
- **Date:** 2026-09

### ADR-017: Build-System C Header Translation

- **Decision:** Translate each C header site with a dedicated `std.Build.addTranslateC` step and a small shim under `src/c/`, then expose the generated declarations to the executable through named private modules. The SDL and SDL_ttf shim is shared by `c.zig`; PTY, libc, time, and macOS process APIs each retain their own module.
- **Context:** Zig 0.16 provides build-system translation as the supported path for C headers. Architect also has platform-specific headers: the PTY shim varies by target, while the libproc and sysctl shims are only valid on macOS. SDL translation must share the executable's environment-provided include paths and macOS SDK framework path.
- **Alternatives considered:**
- *One monolithic C translation module* -- rejected because a platform-specific or difficult header would make unrelated C declarations fail together and would require non-macOS builds to analyze macOS-only APIs.
- *Direct compiler-side header imports* -- rejected because the build-system translation step is the supported Zig 0.16 integration and makes target-specific inputs explicit in `build.zig`.
- **Consequences:** Each C import site has an isolated translation failure boundary, target-specific headers are only created and imported when applicable, and `src/c.zig` remains the stable SDL symbol surface for the rest of the codebase. The executable continues to own the SDL3, SDL3_ttf, proc, and framework link configuration.
- **Date:** 2026-09
7 changes: 7 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,13 @@ zig build run -- --log-dir .tmp/architect-debug-logs
- **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 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
provided outside the compiler's default search paths, `SDL3_INCLUDE_PATH` and
`SDL3_TTF_INCLUDE_PATH` supply the include paths to both translation and
compilation. On macOS, framework headers use the SDK path discovered from
`SDKROOT`, `DEVELOPER_DIR`, or `xcrun`, in that order.

## Tests and Formatting

Run tests:
Expand Down
9 changes: 3 additions & 6 deletions src/c.zig
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
// Minimal re-export layer that isolates C includes so the rest of the codebase
// can `@import("c.zig")` without pulling headers repeatedly.
// Minimal re-export layer that keeps the translated SDL symbols in one place
// so the rest of the codebase can `@import("c.zig")` without repeating names.
// zwanzig-disable: identifier-style
const c_import = @cImport({
@cInclude("SDL3/SDL.h");
@cInclude("SDL3_ttf/SDL_ttf.h");
});
const c_import = @import("c_sdl");

pub const SDL_Init = c_import.SDL_Init;
pub const SDL_Quit = c_import.SDL_Quit;
Expand Down
1 change: 1 addition & 0 deletions src/c/libc_stdlib.h
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#include <stdlib.h>
1 change: 1 addition & 0 deletions src/c/libc_time.h
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#include <time.h>
2 changes: 2 additions & 0 deletions src/c/libproc.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include <libproc.h>
#include <sys/proc_info.h>
2 changes: 2 additions & 0 deletions src/c/pty_freebsd.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include <termios.h>
#include <libutil.h>
2 changes: 2 additions & 0 deletions src/c/pty_linux.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include <sys/ioctl.h>
#include <pty.h>
2 changes: 2 additions & 0 deletions src/c/pty_macos.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include <sys/ioctl.h>
#include <util.h>
2 changes: 2 additions & 0 deletions src/c/sdl.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
3 changes: 3 additions & 0 deletions src/c/sysctl.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#include <sys/types.h>
#include <sys/sysctl.h>
#include <sys/proc.h>
5 changes: 1 addition & 4 deletions src/cwd.zig
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,7 @@ pub const CwdError = error{
OutOfMemory,
};

const c = @cImport({
@cInclude("libproc.h");
@cInclude("sys/proc_info.h");
});
const c = @import("c_libproc");

pub fn getCwd(allocator: std.mem.Allocator, pid: std.c.pid_t) CwdError![]const u8 {
var vnode_info: c.struct_proc_vnodepathinfo = undefined;
Expand Down
4 changes: 1 addition & 3 deletions src/logging.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,7 @@ const Dir = std.Io.Dir;
const File = std.Io.File;
const clock = @import("clock.zig");
const env = @import("env.zig");
const time_c = @cImport({
@cInclude("time.h");
});
const time_c = @import("c_time");

pub const active_log_filename = "architect.log";
pub const default_max_file_size_bytes: u64 = 10 * 1024 * 1024;
Expand Down
15 changes: 1 addition & 14 deletions src/pty.zig
Original file line number Diff line number Diff line change
Expand Up @@ -34,20 +34,7 @@ const PosixPty = struct {
const TIOCSWINSZ = if (builtin.os.tag == .macos) 2148037735 else c.TIOCSWINSZ;
const TIOCGWINSZ = if (builtin.os.tag == .macos) 1074295912 else c.TIOCGWINSZ;
extern "c" fn setsid() std.c.pid_t;
const c = switch (builtin.os.tag) {
.macos => @cImport({
@cInclude("sys/ioctl.h");
@cInclude("util.h");
}),
.freebsd => @cImport({
@cInclude("termios.h");
@cInclude("libutil.h");
}),
else => @cImport({
@cInclude("sys/ioctl.h");
@cInclude("pty.h");
}),
};
const c = @import("c_pty");

master: Fd,
slave: Fd,
Expand Down
6 changes: 1 addition & 5 deletions src/session/state.zig
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@ const vt_stream = @import("../vt_stream.zig");
const pty_reader_mod = @import("pty_reader.zig");
const posix_util = @import("../posix_util.zig");
const mac = if (builtin.os.tag == .macos)
@cImport({
@cInclude("sys/types.h");
@cInclude("sys/sysctl.h");
@cInclude("sys/proc.h");
})
@import("c_sysctl")
else
struct {};

Expand Down
4 changes: 1 addition & 3 deletions src/shell.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@ const env = @import("env.zig");
const proc = @import("proc.zig");
const pty_mod = @import("pty.zig");
const posix_util = @import("posix_util.zig");
const libc = @cImport({
@cInclude("stdlib.h");
});
const libc = @import("c_stdlib");

const log = std.log.scoped(.shell);

Expand Down