Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **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
- **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches
- **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 arrow key selection
- **Diff review comments** — click diff lines in the ⌘D overlay to leave inline comments, then send them all to a running agent (or start one) with the "Send to agent" button
- **Story viewer** — run `architect story <filename>` to open a scrollable overlay that renders PR story files with prose text and diff-colored code blocks
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ Renderer draws attention border / story overlay
| Terminal cell buffer | In-memory (ghostty-vt) | Current screen + scrollback (up to 10KB default) |
| Glyph cache | GPU textures + in-memory LRU | Up to 4096 shaped glyph textures |
| Render cache | GPU textures per session | Cached terminal renders, epoch-invalidated |
| config.toml | `~/.config/architect/config.toml` | User preferences (font, theme, UI flags) |
| config.toml | `~/.config/architect/config.toml` | User preferences (font, theme, UI flags, worktree location) |
| persistence.toml | `~/.config/architect/persistence.toml` | Runtime state (window pos, font size, terminal cwds) |
| diff_comments.json | `<repo>/.architect/diff_comments.json` | Per-repo inline diff review comments (unsent) |

Expand All @@ -283,7 +283,7 @@ Renderer draws attention border / story overlay
|--------|---------------|----------------------------------|--------------|
| `main.zig` | Thin entrypoint | `main()` | `app/runtime` |
| `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence | `run()`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, all `app/*` modules |
| `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, worktree commands | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` |
| `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, worktree commands (with configurable external directory and post-create init) | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()`, `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`) |
Expand Down
23 changes: 23 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ When enabled, press `Cmd+Shift+M` to toggle the metrics overlay in the bottom-ri

Metrics collection has zero overhead when disabled (no allocations, null pointer checks compile away).

### Worktree Configuration

```toml
[worktree]
directory = "~/.architect-worktrees" # Base directory for new worktrees
init_command = "script/setup" # Command to run after creating a worktree
```

| Setting | Default | Description |
|---------|---------|-------------|
| `directory` | `~/.architect-worktrees` | Base directory where new worktrees are created. Each repo gets a subdirectory named after the repo, and each worktree is a subdirectory within that. Supports `~` expansion. |
| `init_command` | *(auto-detect)* | Shell command to run in the new worktree after creation. When not set, Architect automatically runs `script/setup` or `.architect-init.sh` if either exists and is executable. |

New worktrees are created at `<directory>/<repo-name>/<worktree-name>`. For example, with the default directory and a repo named `myproject`, creating a worktree called `feature-x` produces `~/.architect-worktrees/myproject/feature-x`.

Creating worktrees outside the repository tree prevents agents (Claude Code, Codex, etc.) from discovering the parent repository's configuration files when traversing up the directory tree.

Existing worktrees (including legacy ones under `.architect/` inside the repo) remain visible in the worktree picker and can be switched to or removed normally.

### Complete Example

```toml
Expand Down Expand Up @@ -183,6 +202,10 @@ vsync = true

[metrics]
enabled = false

[worktree]
# directory = "~/.architect-worktrees"
# init_command = "script/setup"
```

## persistence.toml
Expand Down
31 changes: 22 additions & 9 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1744,7 +1744,25 @@ pub fn run() !void {
continue;
}

const command = worktree.buildCreateWorktreeCommand(allocator, create_action.base_path, create_action.name) catch |err| {
const target_path = worktree.resolveWorktreeDir(
allocator,
create_action.base_path,
create_action.name,
config.worktree.directory,
) catch |err| {
std.debug.print("Failed to resolve worktree directory: {}\n", .{err});
ui.showToast("Could not create worktree", now);
continue;
};
defer allocator.free(target_path);

const command = worktree.buildCreateWorktreeCommand(
allocator,
create_action.base_path,
target_path,
create_action.name,
config.worktree.init_command,
) catch |err| {
std.debug.print("Failed to build worktree command: {}\n", .{err});
ui.showToast("Could not create worktree", now);
continue;
Expand All @@ -1757,14 +1775,9 @@ pub fn run() !void {
continue;
};

// Update cwd to the new worktree path for UI purposes.
const new_path = std.fs.path.join(allocator, &.{ create_action.base_path, ".architect", create_action.name }) catch null;
if (new_path) |abs| {
session.recordCwd(abs) catch |err| {
log.warn("session {d}: failed to record cwd: {}", .{ create_action.session, err });
};
allocator.free(abs);
}
session.recordCwd(target_path) catch |err| {
log.warn("session {d}: failed to record cwd: {}", .{ create_action.session, err });
};

session_interaction_component.setStatus(create_action.session, .running);
session_interaction_component.setAttention(create_action.session, false, now);
Expand Down
63 changes: 52 additions & 11 deletions src/app/worktree.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,24 +37,65 @@ pub fn changeSessionDirectory(session: *SessionState, allocator: std.mem.Allocat
try session.recordCwd(path);
}

pub fn buildCreateWorktreeCommand(allocator: std.mem.Allocator, base_path: []const u8, name: []const u8) ![]u8 {
/// Resolve the absolute worktree target directory for a new worktree.
/// If `config_dir` is set, expands `~` and appends `<repo-name>/<worktree-name>`.
/// Otherwise defaults to `~/.architect-worktrees/<repo-name>/<worktree-name>`.
pub fn resolveWorktreeDir(allocator: std.mem.Allocator, repo_root: []const u8, name: []const u8, config_dir: ?[]const u8) ![]u8 {
const home = std.posix.getenv("HOME") orelse return error.HomeNotFound;
const repo_name = std.fs.path.basename(repo_root);

const base = if (config_dir) |dir|
if (dir.len > 0 and dir[0] == '~')
try std.fs.path.join(allocator, &.{ home, dir[1..], repo_name })
else
try std.fs.path.join(allocator, &.{ dir, repo_name })
else
try std.fs.path.join(allocator, &.{ home, ".architect-worktrees", repo_name });
defer allocator.free(base);

return std.fs.path.join(allocator, &.{ base, name });
}

pub fn buildCreateWorktreeCommand(
allocator: std.mem.Allocator,
repo_root: []const u8,
target_path: []const u8,
name: []const u8,
init_command: ?[]const u8,
) ![]u8 {
var cmd: std.ArrayList(u8) = .empty;
errdefer cmd.deinit(allocator);

// cd to repo root so git worktree add runs in the right context
try cmd.appendSlice(allocator, clear_line_prefix ++ "cd -- ");
try appendQuotedPath(&cmd, allocator, base_path);
try cmd.appendSlice(allocator, " && mkdir -p .architect && git worktree add ");

const target_rel = try std.fmt.allocPrint(allocator, ".architect/{s}", .{name});
defer allocator.free(target_rel);

try appendQuotedPath(&cmd, allocator, target_rel);
try appendQuotedPath(&cmd, allocator, repo_root);

// create parent directory and add worktree
const parent = std.fs.path.dirname(target_path) orelse target_path;
try cmd.appendSlice(allocator, " && mkdir -p ");
try appendQuotedPath(&cmd, allocator, parent);
try cmd.appendSlice(allocator, " && git worktree add ");
try appendQuotedPath(&cmd, allocator, target_path);
try cmd.appendSlice(allocator, " -b ");
try appendQuotedPath(&cmd, allocator, name);
try cmd.appendSlice(allocator, " && cd -- ");
try appendQuotedPath(&cmd, allocator, target_rel);
try cmd.appendSlice(allocator, "\n");

// cd into the new worktree
try cmd.appendSlice(allocator, " && cd -- ");
try appendQuotedPath(&cmd, allocator, target_path);

// run init command (explicit or auto-detected)
if (init_command) |ic| {
try cmd.appendSlice(allocator, " && ");
try cmd.appendSlice(allocator, ic);
} else {
try cmd.appendSlice(
allocator,
" && if [ -x script/setup ]; then script/setup;" ++
" elif [ -x .architect-init.sh ]; then ./.architect-init.sh; fi",
);
}

try cmd.append(allocator, '\n');
return cmd.toOwnedSlice(allocator);
}

Expand Down
40 changes: 40 additions & 0 deletions src/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,38 @@ pub const MetricsConfig = struct {
enabled: bool = false,
};

pub const WorktreeConfig = struct {
directory: ?[]const u8 = null,
init_command: ?[]const u8 = null,
directory_owned: bool = false,
init_command_owned: bool = false,

pub fn deinit(self: *WorktreeConfig, allocator: std.mem.Allocator) void {
if (self.directory_owned) {
if (self.directory) |v| allocator.free(v);
}
if (self.init_command_owned) {
if (self.init_command) |v| allocator.free(v);
}
self.directory = null;
self.init_command = null;
self.directory_owned = false;
self.init_command_owned = false;
}

pub fn duplicate(self: WorktreeConfig, allocator: std.mem.Allocator) !WorktreeConfig {
const dir_dup = if (self.directory) |d| try allocator.dupe(u8, d) else null;
errdefer if (dir_dup) |d| allocator.free(d);
const cmd_dup = if (self.init_command) |c| try allocator.dupe(u8, c) else null;
return WorktreeConfig{
.directory = dir_dup,
.init_command = cmd_dup,
.directory_owned = dir_dup != null,
.init_command_owned = cmd_dup != null,
};
}
};

pub const Persistence = struct {
const terminal_key_prefix = "terminal_";
const max_recent_folders: usize = 10;
Expand Down Expand Up @@ -601,6 +633,7 @@ pub const Config = struct {
ui: UiConfig = .{},
rendering: Rendering = .{},
metrics: MetricsConfig = .{},
worktree: WorktreeConfig = .{},

pub fn load(allocator: std.mem.Allocator) LoadError!Config {
const config_path = try getConfigPath(allocator);
Expand Down Expand Up @@ -679,6 +712,11 @@ pub const Config = struct {
\\# [metrics]
\\# enabled = false
\\
\\# Worktree options
\\# [worktree]
\\# directory = "~/.architect-worktrees" # Base directory for new worktrees (default: ~/.architect-worktrees)
\\# init_command = "script/setup" # Command to run after creating a worktree
\\
;

const file = try fs.createFileAbsolute(config_path, .{ .truncate = true });
Expand Down Expand Up @@ -711,13 +749,15 @@ pub const Config = struct {

config.font = try config.font.duplicate(allocator);
config.theme = try config.theme.duplicate(allocator);
config.worktree = try config.worktree.duplicate(allocator);

return config;
}

pub fn deinit(self: *Config, allocator: std.mem.Allocator) void {
self.font.deinit(allocator);
self.theme.deinit(allocator);
self.worktree.deinit(allocator);
}

pub fn getFontSize(self: Config) i32 {
Expand Down