diff --git a/CLAUDE.md b/CLAUDE.md index 2bb6d7c3..83123970 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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`. diff --git a/build.zig b/build.zig index 00f9b1ba..6746b302 100644 --- a/build.zig +++ b/build.zig @@ -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, @@ -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; @@ -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); @@ -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 { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 545dca84..b37785d3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -22,7 +22,7 @@ graph TD subgraph Platform Layer SDL["platform/sdl.zig
SDL3 window, renderer, HiDPI"] IM["input/mapper.zig
Keycodes to VT sequences"] - CZIG["c.zig
C FFI re-exports"] + CZIG["c.zig
SDL C FFI re-exports from c_sdl"] end subgraph Session Layer @@ -489,7 +489,7 @@ Rotate: rename active file to architect-.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) | @@ -574,7 +574,7 @@ Rotate: rename active file to architect-.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. @@ -681,3 +681,13 @@ Rotate: rename active file to architect-.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 diff --git a/docs/development.md b/docs/development.md index b2460323..9033d3ca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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: diff --git a/src/c.zig b/src/c.zig index 1ee5add9..0b9463bd 100644 --- a/src/c.zig +++ b/src/c.zig @@ -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; diff --git a/src/c/libc_stdlib.h b/src/c/libc_stdlib.h new file mode 100644 index 00000000..c8b49f26 --- /dev/null +++ b/src/c/libc_stdlib.h @@ -0,0 +1 @@ +#include diff --git a/src/c/libc_time.h b/src/c/libc_time.h new file mode 100644 index 00000000..91fd1871 --- /dev/null +++ b/src/c/libc_time.h @@ -0,0 +1 @@ +#include diff --git a/src/c/libproc.h b/src/c/libproc.h new file mode 100644 index 00000000..8547a67e --- /dev/null +++ b/src/c/libproc.h @@ -0,0 +1,2 @@ +#include +#include diff --git a/src/c/pty_freebsd.h b/src/c/pty_freebsd.h new file mode 100644 index 00000000..80029d27 --- /dev/null +++ b/src/c/pty_freebsd.h @@ -0,0 +1,2 @@ +#include +#include diff --git a/src/c/pty_linux.h b/src/c/pty_linux.h new file mode 100644 index 00000000..8e12bf0a --- /dev/null +++ b/src/c/pty_linux.h @@ -0,0 +1,2 @@ +#include +#include diff --git a/src/c/pty_macos.h b/src/c/pty_macos.h new file mode 100644 index 00000000..d47a43e7 --- /dev/null +++ b/src/c/pty_macos.h @@ -0,0 +1,2 @@ +#include +#include diff --git a/src/c/sdl.h b/src/c/sdl.h new file mode 100644 index 00000000..1b3df773 --- /dev/null +++ b/src/c/sdl.h @@ -0,0 +1,2 @@ +#include +#include diff --git a/src/c/sysctl.h b/src/c/sysctl.h new file mode 100644 index 00000000..db7fac84 --- /dev/null +++ b/src/c/sysctl.h @@ -0,0 +1,3 @@ +#include +#include +#include diff --git a/src/cwd.zig b/src/cwd.zig index e8b02fab..23a0b7e8 100644 --- a/src/cwd.zig +++ b/src/cwd.zig @@ -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; diff --git a/src/logging.zig b/src/logging.zig index 70b2a943..44ad0815 100644 --- a/src/logging.zig +++ b/src/logging.zig @@ -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; diff --git a/src/pty.zig b/src/pty.zig index 9688e9ce..7595a32a 100644 --- a/src/pty.zig +++ b/src/pty.zig @@ -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, diff --git a/src/session/state.zig b/src/session/state.zig index e06f9610..aeea210c 100644 --- a/src/session/state.zig +++ b/src/session/state.zig @@ -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 {}; diff --git a/src/shell.zig b/src/shell.zig index 2637b78d..4affd169 100644 --- a/src/shell.zig +++ b/src/shell.zig @@ -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);