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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Architect solves this with a grid view that keeps all your agents visible, with
- **Selection-to-agent context** — release after selecting terminal text to reveal a narrow robot button anchored just outside the selection; the button follows the selection while scrolling; choose Claude, Codex, or Gemini in a centered modal with highlighted dropdown navigation, review the fully wrapped context in a scrollable field, enter wrapped multiline instructions, cancel if needed, or launch a new agent in the selected terminal's working directory with the selection submitted as its initial prompt without embedding large contexts in the shell command line
- **Story viewer** — run `architect story <filename>` to open a scrollable overlay that renders PR story files with prose text and diff-colored code blocks
- **MCP session spawning** — run `architect-mcp` from an MCP client to ask the running Architect app to create a terminal session in a requested working directory
- **Reader mode** (⌘R) — open a centered markdown reader for the selected terminal's history (works in full view and grid) with live updates, bottom pinning, incremental search (⌘F, Enter/Shift+Enter), markdown tables with inline cell styling (bold/italic/code/links/strikethrough), task checkboxes (emoji), clickable links, shared draggable scrollbar, and left-to-right gradient separators before command prompts (OSC 133 + fallback heuristics)
- **Reader mode** (⌘R) — open a centered markdown snapshot of the selected terminal's history (works in full view and grid); close and reopen it to capture newer output. Includes bottom pinning, incremental search (⌘F, Enter/Shift+Enter), markdown tables with inline cell styling (bold/italic/code/links/strikethrough), task checkboxes (emoji), clickable links, shared draggable scrollbar, and left-to-right gradient separators before command prompts (OSC 133 + fallback heuristics)

### Terminal Essentials
- Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together)
Expand Down
4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ ui/components/markdown_parser.DisplayBlock[]
ui/components/markdown_renderer.RenderLine[]
| render as SDL text runs in centered reader column
v
Reader overlay (live updates + search)
Reader overlay (open-time snapshot + search)
```

### Story Content Path
Expand Down Expand Up @@ -510,7 +510,7 @@ Rotate: rename active file to architect-<UTC timestamp>.log and continue in new
| `ui/components/markdown_parser.zig` | Shared markdown parser for reader mode and story overlays. Parses headings, paragraphs, lists (including task checkboxes), blockquotes, markdown tables, fenced code, horizontal rules, inline styles (bold/italic/code/strikethrough/link), and prompt separator blocks. In story mode (`parseStory()`), additionally handles `story-diff` fenced blocks, code block metadata JSON, anchor extraction (`**[N]**` in prose, `<!--ref:N-->` in code), and per-line paragraph emission. | `parse()`, `parseStory()`, `freeBlocks()`, `DisplayBlock`, `StyledSpan`, `CodeBlockMeta`, `CodeLineKind`, `ParseOptions` | std |
| `ui/components/markdown_renderer.zig` | Line layout engine that wraps parsed markdown blocks into renderable lines and style runs, including prompt-separator and story-specific line kinds (diff headers, diff lines, code lines with anchor/kind metadata) | `buildLines()`, `freeLines()`, `RenderLine`, `RenderRun` | `ui/components/markdown_parser` |
| `ui/components/search_utils.zig` | Shared search utilities for overlays: case-insensitive substring find, match rebuilding, search bar rendering, and text texture creation | `SearchMatch`, `TextTex`, `findCaseInsensitive()`, `rebuildMatches()`, `renderSearchBar()`, `makeTextTexture()` | `gfx/primitives`, `font_cache`, `dpi`, `geom`, `c` |
| `ui/components/reader_overlay.zig` | Fullscreen reader overlay for the selected terminal history (full view or grid selection) with live markdown updates, centered reading-width layout, bottom pinning, jump-to-bottom, incremental search, clickable links, shared scrollbar interactions, styled inline markdown in table cells, and left-to-right gradient prompt separators | `ReaderOverlayComponent`, `toggle()` | `ui/components/fullscreen_overlay`, `ui/components/scrollbar`, `ui/components/search_utils`, `app/terminal_history`, `ui/components/markdown_parser`, `ui/components/markdown_renderer`, `os/open`, `font_cache`, `geom`, `c` |
| `ui/components/reader_overlay.zig` | Fullscreen reader overlay for an open-time snapshot of the selected terminal history (full view or grid selection) with centered reading-width layout, bottom pinning, jump-to-bottom, incremental search, clickable links, shared scrollbar interactions, styled inline markdown in table cells, and left-to-right gradient prompt separators | `ReaderOverlayComponent`, `toggle()` | `ui/components/fullscreen_overlay`, `ui/components/scrollbar`, `ui/components/search_utils`, `app/terminal_history`, `ui/components/markdown_parser`, `ui/components/markdown_renderer`, `os/open`, `font_cache`, `geom`, `c` |
| `ui/components/modal_frame.zig` | Shared chrome for centered modal dialogs: full-window darkening scrim + rounded filled/bordered panel, and the Escape/⌘W dismiss-key check. Used by `confirm_dialog.zig` and `selection_agent_overlay.zig` so their scrim/panel rendering and dismissal keys can't drift independently | `renderScrimAndPanel()`, `isDismissKey()` | `gfx/primitives`, `geom`, `c` |
| `ui/components/dropdown_menu.zig` | Reusable vertical list menu: owns open/hover/keyboard-nav state and the committed `selected` index, renders its own cached item-label textures, and reports a `.selected`/`.closed` event on click or Enter/Escape so the owning component reacts (persist the pick, or act on it immediately) instead of tracking hit-testing and highlight rendering itself. Used by `selection_agent_overlay.zig`'s agent selector and `diff_overlay.zig`'s "Send to agent" menu | `DropdownMenu`, `openMenu()`, `close()`, `handleKey()`, `handleClick()`, `handleMotion()`, `itemAt()`, `itemRect()`, `render()` | `gfx/primitives`, `font_cache`, `ui/text_render`, `geom`, `c` |
| `ui/components/selection_agent_overlay.zig` | Selection action form with highlighted agent selector, multiline prompt field, fully wrapped and scrollable selected-context preview, viewport-bounded context textures, cached UI text, and launch action containing the selected terminal context | `SelectionAgentOverlayComponent`, `open()`, `formatAgentPrompt()` | `ui/text_edit`, `ui/text_render`, `ui/first_frame_guard`, `ui/components/modal_frame`, `ui/components/dropdown_menu`, `ui/components/scrollbar`, `gfx/primitives`, `font_cache`, `geom`, `c` |
Expand Down
1 change: 1 addition & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ test {
_ = @import("ui/components/pr_dropdown_model.zig");
_ = @import("ui/components/pr_dropdown_repo.zig");
_ = @import("ui/components/pr_dropdown_view.zig");
_ = @import("ui/components/reader_overlay.zig");
_ = @import("ui/components/scrollbar.zig");
_ = @import("ui/components/search_utils.zig");
_ = @import("ui/components/selection_agent_overlay.zig");
Expand Down
66 changes: 53 additions & 13 deletions src/ui/components/reader_overlay.zig
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ pub const ReaderOverlayComponent = struct {
scrollbar_state: scrollbar.State = .{},

session_index: usize = 0,
last_render_epoch: u64 = 0,
wrap_cols: usize = 90,
layout_char_w_px: c_int = 0,
pinned_to_bottom: bool = true,
Expand Down Expand Up @@ -131,7 +130,7 @@ pub const ReaderOverlayComponent = struct {
self.layout_char_w_px = 0;
self.wrap_cols = self.computeWrapCols(host);
self.overlay.show(now_ms);
self.refreshFromSession(host, true);
self.loadSnapshotFromSession(host, true);
return .opened;
}

Expand Down Expand Up @@ -169,15 +168,14 @@ pub const ReaderOverlayComponent = struct {
markdown_renderer.freeLines(self.allocator, &self.lines);
self.blocks = .empty;
self.lines = .empty;
self.last_render_epoch = 0;
}

fn clearLinkHits(self: *ReaderOverlayComponent) void {
self.link_hits.clearRetainingCapacity();
self.hovered_link = null;
}

fn refreshFromSession(self: *ReaderOverlayComponent, host: *const types.UiHost, force_bottom: bool) void {
fn loadSnapshotFromSession(self: *ReaderOverlayComponent, host: *const types.UiHost, force_bottom: bool) void {
if (self.session_index >= self.sessions.len) return;

const session = self.sessions[self.session_index];
Expand All @@ -197,7 +195,6 @@ pub const ReaderOverlayComponent = struct {
};

self.rebuildLines(host, force_bottom);
self.last_render_epoch = session.render_epoch;
}

fn rebuildLines(self: *ReaderOverlayComponent, host: *const types.UiHost, force_bottom: bool) void {
Expand Down Expand Up @@ -875,14 +872,9 @@ pub const ReaderOverlayComponent = struct {
self.rebuildLines(host, false);
}

if (self.session_index < self.sessions.len) {
const session = self.sessions[self.session_index];
if (session.render_epoch != self.last_render_epoch) {
self.refreshFromSession(host, false);
} else {
_ = self.syncScrollMetrics(host);
}
}
// Reader content is an open-time snapshot. Terminal output continues
// independently and is captured the next time the overlay opens.
_ = self.syncScrollMetrics(host);

if (self.pinned_to_bottom) {
self.overlay.scroll_offset = self.overlay.max_scroll;
Expand Down Expand Up @@ -1695,3 +1687,51 @@ pub const ReaderOverlayComponent = struct {
.wantsFrame = wantsFrameFn,
};
};

test "reader content stays a snapshot while the terminal updates" {
const allocator = std.testing.allocator;

var session: SessionState = undefined;
session.render_epoch = 2;
session.terminal = null;
var sessions = [_]*SessionState{&session};

var component = ReaderOverlayComponent{
.allocator = allocator,
.opener = undefined,
.sessions = &sessions,
.overlay = .{
.visible = true,
.animation_state = .open,
},
.session_index = 0,
.raw_text = try allocator.dupe(u8, "captured before live output"),
};
defer component.clearContent();

var host = types.UiHost{
.now_ms = 1_000,
.window_w = 1_200,
.window_h = 800,
.window_focused = true,
.ui_scale = 1.0,
.grid_cols = 1,
.grid_rows = 1,
.cell_w = 10,
.cell_h = 20,
.term_cols = 120,
.term_rows = 40,
.view_mode = .Full,
.focused_session = 0,
.focused_cwd = null,
.focused_has_foreground_process = true,
.sessions = &[_]types.SessionUiInfo{},
.theme = undefined,
};
var actions = types.UiActionQueue.init(allocator);
defer actions.deinit();

ReaderOverlayComponent.updateFn(&component, &host, &actions);

try std.testing.expectEqualStrings("captured before live output", component.raw_text.?);
}