Skip to content
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ The `<= len` pattern is only correct when `pos` represents a position *after* pr
## Claude Socket Hook
- The app creates `${XDG_RUNTIME_DIR:-/tmp}/architect_notify_<pid>.sock` and sets `ARCHITECT_SESSION_ID`/`ARCHITECT_NOTIFY_SOCK` for each shell.
- Send a single JSON line to signal UI states: `{"session":N,"state":"start"|"awaiting_approval"|"done"}`. The helper `scripts/architect_notify.py` is available if needed.
- Story notifications use the same socket: `{"session":N,"type":"story","path":"/absolute/path/to/story.md"}`. The `architect story <file>` subcommand sends this automatically.

## Done? Share
- Provide a concise summary of edits, test/build outcomes, and documentation updates.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Architect solves this with a grid view that keeps all your agents visible, with
- **Worktree picker** (⌘T) — quickly `cd` into git worktrees for parallel agent work on separate branches
- **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

### Terminal Essentials
- Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together)
Expand Down
10 changes: 6 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,14 +236,16 @@ External tool (Claude Code, Codex, Gemini)
v
session/notify.zig (background thread)
| parse {"session": N, "state": "awaiting_approval"}
| or {"session": N, "type": "story", "path": "/abs/path"}
v
NotificationQueue (thread-safe)
| main loop drains each frame
v
SessionStatus updated (idle -> awaiting_approval)
Status notifications -> SessionStatus updated (idle -> awaiting_approval)
Story notifications -> StoryOverlay opens with file content
|
v
Renderer draws attention border (pulsing yellow / solid green)
Renderer draws attention border / story overlay
```

### Entry Points
Expand Down Expand Up @@ -286,7 +288,7 @@ Renderer draws attention border (pulsing yellow / solid green)
| `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`) |
| `session/state.zig` | Terminal session lifecycle: PTY, ghostty-vt, process watcher | `SessionState`, `init()`, `deinit()`, `ensureSpawnedWithDir()`, `render_epoch`, `pending_write` | `shell`, `pty`, `vt_stream`, `cwd`, `font`, xev |
| `session/notify.zig` | Background notification socket thread and queue | `NotificationQueue`, `startThread()`, `push()`, `drain()` | std (socket, thread) |
| `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/*` (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 | `render()`, `RenderCache`, per-session texture management | `font`, `font_cache`, `gfx/*`, `anim/easing`, `app/app_state`, `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) |
Expand All @@ -296,7 +298,7 @@ Renderer draws attention border (pulsing yellow / solid green)
| `ui/types.zig` | Shared UI type definitions | `UiHost`, `UiAction`, `UiActionQueue`, `UiAssets`, `SessionUiInfo` | `app/app_state`, `colors`, `font`, `geom` |
| `ui/session_view_state.zig` | Per-session UI interaction state (selection, scroll, hover, agent status) | `SessionViewState` (selection, scroll offset, hover, status) | `app/app_state` (for `SessionStatus` enum) |
| `ui/first_frame_guard.zig` | Idle throttle bypass for visible state transitions | `FirstFrameGuard`, `markTransition()`, `markDrawn()`, `wantsFrame()` | (none) |
| `ui/components/*` | Individual overlay and widget implementations conforming to `UiComponent` vtable. Includes: help overlay, worktree picker, recent folders picker, diff viewer (with inline review comments), session interaction, toast, quit confirm, restart buttons, escape hold indicator, metrics overlay, global shortcuts, pill group, cwd bar, expanding overlay helper, button, confirm dialog, marquee label, hotkey indicator, flowing line, hold gesture detector. | Each component implements the `VTable` interface; overlays toggle via keyboard shortcuts and emit `UiAction` values. | `ui/component`, `ui/types`, `anim/easing`, `font`, `metrics`, `url_matcher`, `ui/session_view_state` |
| `ui/components/*` | Individual overlay and widget implementations conforming to `UiComponent` vtable. Includes: help overlay, worktree picker, recent folders picker, diff viewer (with inline review comments), story viewer (PR story file visualization), session interaction, toast, quit confirm, restart buttons, escape hold indicator, metrics overlay, global shortcuts, pill group, cwd bar, expanding overlay helper, button, confirm dialog, marquee label, hotkey indicator, flowing line, hold gesture detector. | Each component implements the `VTable` interface; overlays toggle via keyboard shortcuts or external commands and emit `UiAction` values. | `ui/component`, `ui/types`, `anim/easing`, `font`, `metrics`, `url_matcher`, `ui/session_view_state` |
| Shared Utilities (`geom`, `colors`, `config`, `metrics`, `url_matcher`, `os/open`, `anim/easing`) | Geometry primitives, theme/palette management, TOML config loading/persistence, performance metrics, URL detection, cross-platform URL opening, easing functions | `Rect`, `Theme`, `Config`, `Metrics`, `matchUrl()`, `open()`, `easeInOutCubic()`, `easeOutCubic()` | std, zig-toml, `c` |

## Key Architectural Decisions
Expand Down
36 changes: 27 additions & 9 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,8 @@ pub fn run() !void {
try ui.register(metrics_overlay_component.asComponent());
const diff_overlay_component = try ui_mod.diff_overlay.DiffOverlayComponent.init(allocator);
try ui.register(diff_overlay_component.asComponent());
const story_overlay_component = try ui_mod.story_overlay.StoryOverlayComponent.init(allocator);
try ui.register(story_overlay_component.asComponent());

// Main loop: handle SDL input, feed PTY output into terminals, apply async
// notifications, drive animations, and render at ~60 FPS.
Expand Down Expand Up @@ -1411,15 +1413,25 @@ pub fn run() !void {
defer notifications.deinit(allocator);
const had_notifications = notifications.items.len > 0;
for (notifications.items) |note| {
const session_idx = findSessionIndexById(sessions, note.session) orelse continue;
session_interaction_component.setStatus(session_idx, note.state);
const wants_attention = switch (note.state) {
.awaiting_approval, .done => true,
else => false,
};
const is_focused_full = anim_state.mode == .Full and anim_state.focused_session == session_idx;
session_interaction_component.setAttention(session_idx, if (is_focused_full) false else wants_attention);
std.debug.print("Session {d} (slot {d}) status -> {s}\n", .{ note.session, session_idx, @tagName(note.state) });
switch (note) {
.status => |s| {
const session_idx = findSessionIndexById(sessions, s.session) orelse continue;
session_interaction_component.setStatus(session_idx, s.state);
const wants_attention = switch (s.state) {
.awaiting_approval, .done => true,
else => false,
};
const is_focused_full = anim_state.mode == .Full and anim_state.focused_session == session_idx;
session_interaction_component.setAttention(session_idx, if (is_focused_full) false else wants_attention);
std.debug.print("Session {d} (slot {d}) status -> {s}\n", .{ s.session, session_idx, @tagName(s.state) });
},
.story => |s| {
if (!story_overlay_component.show(s.path, now)) {
ui.showToast("Failed to open story file", now);
}
allocator.free(s.path);
},
}
}

if (pending_comment_send) |pcs| {
Expand Down Expand Up @@ -1887,6 +1899,12 @@ pub fn run() !void {
allocator.free(dc_action.comments_text);
}
},
.OpenStory => |story_action| {
if (!story_overlay_component.show(story_action.path, now)) {
ui.showToast("Failed to open story file", now);
}
allocator.free(story_action.path);
},
};

if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or
Expand Down
2 changes: 2 additions & 0 deletions src/c.zig
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ pub const SDLK_LEFT = c_import.SDLK_LEFT;
pub const SDLK_RIGHT = c_import.SDLK_RIGHT;
pub const SDLK_HOME = c_import.SDLK_HOME;
pub const SDLK_END = c_import.SDLK_END;
pub const SDLK_PAGEUP = c_import.SDLK_PAGEUP;
pub const SDLK_PAGEDOWN = c_import.SDLK_PAGEDOWN;
pub const SDLK_AC_HOME = c_import.SDLK_AC_HOME;
pub const SDLK_AC_END = c_import.SDLK_AC_END;
pub const SDLK_DELETE = c_import.SDLK_DELETE;
Expand Down
74 changes: 54 additions & 20 deletions src/session/notify.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,22 @@ const atomic = std.atomic;

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

pub const Notification = struct {
pub const Notification = union(enum) {
status: StatusNotification,
story: StoryNotification,
};

pub const StatusNotification = struct {
session: usize,
state: app_state.SessionStatus,
};

pub const StoryNotification = struct {
session: usize,
/// Heap-allocated path; caller must free after processing.
path: []const u8,
};

pub const NotificationQueue = struct {
mutex: std.Thread.Mutex = .{},
items: std.ArrayListUnmanaged(Notification) = .{},
Expand Down Expand Up @@ -64,7 +75,7 @@ pub fn startNotifyThread(
};

const handler = struct {
fn parseNotification(bytes: []const u8) ?Notification {
fn parseNotification(bytes: []const u8, persistent_alloc: std.mem.Allocator) ?Notification {
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();

Expand All @@ -76,6 +87,27 @@ pub fn startNotifyThread(
if (root != .object) return null;
const obj = root.object;

const session_val = obj.get("session") orelse return null;
if (session_val != .integer) return null;
if (session_val.integer < 0) return null;
const session_idx: usize = @intCast(session_val.integer);

// Check for "type" field to distinguish notification kinds
const type_val = obj.get("type");
if (type_val) |tv| {
if (tv == .string and std.mem.eql(u8, tv.string, "story")) {
const path_val = obj.get("path") orelse return null;
if (path_val != .string) return null;
// Allocate path with persistent allocator so it survives arena cleanup
const path_dupe = persistent_alloc.dupe(u8, path_val.string) catch return null;
return Notification{ .story = .{
.session = session_idx,
.path = path_dupe,
} };
}
}

// Default: status notification
const state_val = obj.get("state") orelse return null;
if (state_val != .string) return null;
const state_str = state_val.string;
Expand All @@ -88,14 +120,10 @@ pub fn startNotifyThread(
else
return null;

const session_val = obj.get("session") orelse return null;
if (session_val != .integer) return null;
if (session_val.integer < 0) return null;

return Notification{
.session = @intCast(session_val.integer),
return Notification{ .status = .{
.session = session_idx,
.state = state,
};
} };
}

fn run(ctx: NotifyContext) !void {
Expand Down Expand Up @@ -170,9 +198,18 @@ pub fn startNotifyThread(

if (buffer.items.len == 0) continue;

if (parseNotification(buffer.items)) |note| {
if (parseNotification(buffer.items, ctx.allocator)) |note| {
ctx.queue.push(ctx.allocator, note) catch |err| {
log.warn("failed to queue notification for session {d}: {}", .{ note.session, err });
const session_id = switch (note) {
.status => |s| s.session,
.story => |s| s.session,
};
log.warn("failed to queue notification for session {d}: {}", .{ session_id, err });
// Free heap-allocated data on failure
switch (note) {
.story => |s| ctx.allocator.free(s.path),
.status => {},
}
};
}
}
Expand All @@ -188,18 +225,15 @@ test "NotificationQueue - push and drain" {
var queue = NotificationQueue{};
defer queue.deinit(allocator);

try queue.push(allocator, .{ .session = 0, .state = .running });
try queue.push(allocator, .{ .session = 1, .state = .awaiting_approval });
try queue.push(allocator, .{ .session = 2, .state = .done });
try queue.push(allocator, .{ .status = .{ .session = 0, .state = .running } });
try queue.push(allocator, .{ .status = .{ .session = 1, .state = .awaiting_approval } });
try queue.push(allocator, .{ .status = .{ .session = 2, .state = .done } });

var items = queue.drainAll();
defer items.deinit(allocator);

try std.testing.expectEqual(@as(usize, 3), items.items.len);
try std.testing.expectEqual(@as(usize, 0), items.items[0].session);
try std.testing.expectEqual(app_state.SessionStatus.running, items.items[0].state);
try std.testing.expectEqual(@as(usize, 1), items.items[1].session);
try std.testing.expectEqual(app_state.SessionStatus.awaiting_approval, items.items[1].state);
try std.testing.expectEqual(@as(usize, 2), items.items[2].session);
try std.testing.expectEqual(app_state.SessionStatus.done, items.items[2].state);
try std.testing.expectEqual(Notification{ .status = .{ .session = 0, .state = .running } }, items.items[0]);
try std.testing.expectEqual(Notification{ .status = .{ .session = 1, .state = .awaiting_approval } }, items.items[1]);
try std.testing.expectEqual(Notification{ .status = .{ .session = 2, .state = .done } }, items.items[2]);
}
28 changes: 28 additions & 0 deletions src/shell.zig
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ const architect_command_script =
\\ print("Usage: architect notify <state|json>", file=sys.stderr)
\\ print(" architect notify (reads stdin)", file=sys.stderr)
\\ print(" architect hook claude|codex|gemini", file=sys.stderr)
\\ print(" architect story <filename>", file=sys.stderr)
\\
\\def timestamp_suffix() -> str:
\\ return datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
Expand Down Expand Up @@ -459,6 +460,33 @@ const architect_command_script =
\\ return install_gemini()
\\ print_usage()
\\ return 1
\\ if cmd == "story":
\\ if len(sys.argv) < 3:
\\ print("Usage: architect story <filename>", file=sys.stderr)
\\ return 1
\\ path = os.path.abspath(sys.argv[2])
\\ if not os.path.isfile(path):
\\ print(f"File not found: {path}", file=sys.stderr)
\\ return 1
\\ session_id = os.environ.get("ARCHITECT_SESSION_ID")
\\ sock_path = os.environ.get("ARCHITECT_NOTIFY_SOCK")
\\ if not session_id or not sock_path:
\\ print("Not running inside Architect", file=sys.stderr)
\\ return 1
\\ message = json.dumps({
\\ "session": int(session_id),
\\ "type": "story",
\\ "path": path
\\ }) + "\n"
\\ try:
\\ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
\\ sock.connect(sock_path)
\\ sock.sendall(message.encode())
\\ sock.close()
\\ except Exception as e:
\\ print(f"Failed to notify Architect: {e}", file=sys.stderr)
\\ return 1
\\ return 0
\\
\\ print_usage()
\\ return 1
Expand Down
Loading