Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ architect hook claude
architect hook codex
architect hook gemini
```
On first launch, Architect shows a faint terminal hint with the core shortcuts (Cmd+N/W/Enter) and the `architect hook` setup command.

## Configuration

Expand Down
1 change: 1 addition & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi
- Attention borders (pulsing yellow for awaiting approval, solid green for done)
- CWD bar with marquee scrolling for long paths
- Scrollback indicator strip
- First-run onboarding hint text rendered inside the terminal view (dimmed)

**UiRoot (src/ui/)** is the registry for UI overlay components and session interaction state:
- Dispatches events topmost-first (by z-index)
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ Auto-managed runtime state. Do not edit manually unless troubleshooting.

```toml
font_size = 14
onboarding_shown = true

[window]
width = 1440
Expand All @@ -212,6 +213,7 @@ terminals = [
| Field | Description |
|-------|-------------|
| `font_size` | Current font size (adjusted with `Cmd++`/`Cmd+-`) |
| `onboarding_shown` | Whether the first-run onboarding hint has been displayed |
| `[window]` | Last window position and dimensions |
| `terminals` | Working directories for each terminal (ordered by session index) |

Expand Down
8 changes: 8 additions & 0 deletions src/app/runtime.zig
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,13 @@ pub fn run() !void {
};
defer persistence.deinit(allocator);
persistence.font_size = std.math.clamp(persistence.font_size, min_font_size, max_font_size);
const show_onboarding = !persistence.onboarding_shown;
if (show_onboarding) {
persistence.onboarding_shown = true;
persistence.save(allocator) catch |err| {
log.warn("Failed to save onboarding state: {}", .{err});
Comment on lines +375 to +379

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Persist onboarding_shown only after hint renders

The flag is persisted as soon as show_onboarding is computed, before any frame is drawn. However the hint itself only renders when the focused session is slot 0, alive, and there’s enough visible rows (see the early-return guard in renderOnboardingHint). If the app starts in a small window or the hint is otherwise skipped on the first run, the persisted onboarding_shown = true will suppress the hint permanently, even after resizing/relaunching. Consider deferring the save until after a successful render (or tracking that the hint was actually displayed).

Useful? React with 👍 / 👎.

};
}

const theme = colors_mod.Theme.fromConfig(config.theme);

Expand Down Expand Up @@ -1834,6 +1841,7 @@ pub fn run() !void {
&theme,
config.grid.font_scale,
&grid,
show_onboarding,
) catch |err| {
log.err("render failed: {}", .{err});
return err;
Expand Down
8 changes: 8 additions & 0 deletions src/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,13 @@ pub const Persistence = struct {
window: WindowConfig = .{},
font_size: c_int = 14,
terminal_paths: std.ArrayListUnmanaged([]const u8) = .{},
onboarding_shown: bool = false,

const TomlPersistenceV2 = struct {
window: WindowConfig = .{},
font_size: c_int = 14,
terminals: ?[]const []const u8 = null,
onboarding_shown: ?bool = null,
};

const TomlPersistenceV1 = struct {
Expand Down Expand Up @@ -283,6 +285,7 @@ pub const Persistence = struct {
defer result.deinit();
persistence.window = result.value.window;
persistence.font_size = result.value.font_size;
persistence.onboarding_shown = result.value.onboarding_shown orelse true;
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When loading v2 configuration with missing onboarding_shown field, defaulting to true assumes the onboarding has been shown. This will prevent first-time users upgrading from an older version without this field from ever seeing the onboarding hint. The default should be false to ensure users see the hint unless explicitly marked as shown.

Suggested change
persistence.onboarding_shown = result.value.onboarding_shown orelse true;
persistence.onboarding_shown = result.value.onboarding_shown orelse false;

Copilot uses AI. Check for mistakes.

if (result.value.terminals) |paths| {
for (paths) |path| {
Expand All @@ -304,6 +307,7 @@ pub const Persistence = struct {

persistence.window = result_v1.value.window;
persistence.font_size = result_v1.value.font_size;
persistence.onboarding_shown = true;

if (result_v1.value.terminals) |stored| {
try persistence.appendLegacyTerminals(allocator, stored);
Expand Down Expand Up @@ -339,6 +343,7 @@ pub const Persistence = struct {
pub fn serializeToWriter(self: Persistence, writer: anytype) !void {
// Write font_size first (top-level scalar)
try writer.print("font_size = {d}\n", .{self.font_size});
try writer.print("onboarding_shown = {}\n", .{self.onboarding_shown});

// Write terminals array before any sections
if (self.terminal_paths.items.len > 0) {
Expand Down Expand Up @@ -831,6 +836,7 @@ test "Persistence save/load round-trip preserves all fields" {
original.window.x = 100;
original.window.y = 200;
original.font_size = 16;
original.onboarding_shown = true;
try original.appendTerminalPath(allocator, "/home/user/project1");
try original.appendTerminalPath(allocator, "/home/user/project2");
try original.appendTerminalPath(allocator, "/tmp/test");
Expand All @@ -853,6 +859,7 @@ test "Persistence save/load round-trip preserves all fields" {

loaded.window = result.value.window;
loaded.font_size = result.value.font_size;
loaded.onboarding_shown = result.value.onboarding_shown orelse true;
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as in the main loading logic: defaulting to true when the field is missing will cause the test to incorrectly validate behavior for missing fields. Should default to false for consistency with the expected behavior of showing onboarding to new users.

Suggested change
loaded.onboarding_shown = result.value.onboarding_shown orelse true;
loaded.onboarding_shown = result.value.onboarding_shown orelse false;

Copilot uses AI. Check for mistakes.

if (result.value.terminals) |paths| {
for (paths) |path| {
Expand All @@ -865,6 +872,7 @@ test "Persistence save/load round-trip preserves all fields" {
try std.testing.expectEqual(original.window.x, loaded.window.x);
try std.testing.expectEqual(original.window.y, loaded.window.y);
try std.testing.expectEqual(original.font_size, loaded.font_size);
try std.testing.expectEqual(original.onboarding_shown, loaded.onboarding_shown);
try std.testing.expectEqual(original.terminal_paths.items.len, loaded.terminal_paths.items.len);

for (original.terminal_paths.items, loaded.terminal_paths.items) |orig_path, load_path| {
Expand Down
81 changes: 68 additions & 13 deletions src/render/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ pub fn render(
theme: *const colors.Theme,
grid_font_scale: f32,
grid: ?*const GridLayout,
show_onboarding: bool,
) RenderError!void {
_ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255);
_ = c.SDL_RenderClear(renderer);
Expand Down Expand Up @@ -122,13 +123,13 @@ pub fn render(
};

const entry = render_cache.entry(i);
try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, true, font, term_cols, term_rows, current_time, theme);
try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, true, font, term_cols, term_rows, current_time, theme, show_onboarding);
}
},
.Full => {
const full_rect = Rect{ .x = 0, .y = 0, .w = window_width, .h = window_height };
const entry = render_cache.entry(anim_state.focused_session);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, full_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme, show_onboarding);
},
.PanningLeft, .PanningRight => {
const elapsed = current_time - anim_state.start_time;
Expand All @@ -140,15 +141,15 @@ pub fn render(

const prev_rect = Rect{ .x = pan_offset, .y = 0, .w = window_width, .h = window_height };
const prev_entry = render_cache.entry(anim_state.previous_session);
try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme);
try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme, show_onboarding);

const new_offset = if (anim_state.mode == .PanningLeft)
window_width - offset
else
-window_width + offset;
const new_rect = Rect{ .x = new_offset, .y = 0, .w = window_width, .h = window_height };
const new_entry = render_cache.entry(anim_state.focused_session);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme, show_onboarding);
},
.PanningUp, .PanningDown => {
const elapsed = current_time - anim_state.start_time;
Expand All @@ -160,15 +161,15 @@ pub fn render(

const prev_rect = Rect{ .x = 0, .y = pan_offset, .w = window_width, .h = window_height };
const prev_entry = render_cache.entry(anim_state.previous_session);
try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme);
try renderSession(renderer, sessions[anim_state.previous_session], &views[anim_state.previous_session], prev_entry, prev_rect, 1.0, false, false, font, term_cols, term_rows, current_time, false, theme, show_onboarding);

const new_offset = if (anim_state.mode == .PanningUp)
window_height - offset
else
-window_height + offset;
const new_rect = Rect{ .x = 0, .y = new_offset, .w = window_width, .h = window_height };
const new_entry = render_cache.entry(anim_state.focused_session);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], new_entry, new_rect, 1.0, true, false, font, term_cols, term_rows, current_time, false, theme, show_onboarding);
},
.Expanding, .Collapsing => {
const animating_rect = anim_state.getCurrentRect(current_time);
Expand Down Expand Up @@ -196,13 +197,13 @@ pub fn render(
};

const entry = render_cache.entry(i);
try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, true, font, term_cols, term_rows, current_time, theme);
try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, false, true, true, font, term_cols, term_rows, current_time, theme, show_onboarding);
}
}

const apply_effects = anim_scale < 0.999;
const entry = render_cache.entry(anim_state.focused_session);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, true, theme);
try renderSession(renderer, sessions[anim_state.focused_session], &views[anim_state.focused_session], entry, animating_rect, anim_scale, true, apply_effects, font, term_cols, term_rows, current_time, true, theme, show_onboarding);
},
.GridResizing => {
// Render session contents first so borders draw on top.
Expand Down Expand Up @@ -235,7 +236,7 @@ pub fn render(
};

const entry = render_cache.entry(i);
try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, false, font, term_cols, term_rows, current_time, theme);
try renderGridSessionCached(renderer, session, &views[i], entry, cell_rect, grid_scale, i == anim_state.focused_session, true, false, font, term_cols, term_rows, current_time, theme, show_onboarding);
}

// Render borders and overlays on top of the animated content.
Expand Down Expand Up @@ -285,8 +286,9 @@ fn renderSession(
current_time_ms: i64,
is_grid_view: bool,
theme: *const colors.Theme,
show_onboarding: bool,
) RenderError!void {
try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, theme);
try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, theme, show_onboarding);
renderSessionOverlays(renderer, view, rect, is_focused, apply_effects, current_time_ms, is_grid_view, theme);
cache_entry.presented_epoch = session.render_epoch;
}
Expand All @@ -302,6 +304,7 @@ fn renderSessionContent(
term_cols: u16,
term_rows: u16,
theme: *const colors.Theme,
show_onboarding: bool,
) RenderError!void {
if (!session.spawned) return;

Expand Down Expand Up @@ -559,6 +562,19 @@ fn renderSessionContent(
try flushRun(font, run_buf[0..], run_len, run_x, origin_y + @as(c_int, @intCast(row)) * cell_height_actual, run_cells, cell_width_actual, cell_height_actual, run_fg, run_variant);
}

if (show_onboarding and is_focused and session.slot_index == 0 and !session.dead) {
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The compound boolean condition checks multiple unrelated properties. Consider extracting this into a helper function shouldShowOnboarding that takes these parameters and returns a boolean, improving readability and testability.

Copilot uses AI. Check for mistakes.
try renderOnboardingHint(
font,
origin_x,
origin_y,
cell_width_actual,
cell_height_actual,
visible_cols,
visible_rows,
theme,
);
}

if (session.dead) {
const message = "[Process completed]";
const message_row: usize = @intCast(cursor.y);
Expand All @@ -577,6 +593,44 @@ fn renderSessionContent(
}
}

fn renderOnboardingHint(
font: *font_mod.Font,
origin_x: c_int,
origin_y: c_int,
cell_width_actual: c_int,
cell_height_actual: c_int,
visible_cols: usize,
visible_rows: usize,
theme: *const colors.Theme,
) RenderError!void {
const lines = [_][]const u8{
"Welcome to Architect",
"Cmd+N adds a terminal, Cmd+W closes one.",
Copy link

Copilot AI Jan 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected spelling of 'recieve' to 'receive'.

Copilot uses AI. Check for mistakes.
"Cmd+Enter toggles grid/full view.",
"Run `architect hook claude|codex|gemini`",
"to set up hooks.",
};
if (visible_rows <= lines.len + 1) return;

var hint_color = applyFaint(theme.foreground);
hint_color.a = 200;

const start_row: usize = visible_rows - lines.len - 1;
for (lines, 0..) |line, idx| {
const row = start_row + idx;
if (row >= visible_rows) break;
const line_y = origin_y + @as(c_int, @intCast(row)) * cell_height_actual;
var offset_x = origin_x;
var col: usize = 0;
for (line) |ch| {
if (col >= visible_cols) break;
try font.renderGlyph(ch, offset_x, line_y, cell_width_actual, cell_height_actual, hint_color);
offset_x += cell_width_actual;
col += 1;
}
}
}

fn renderSessionOverlays(
renderer: *c.SDL_Renderer,
view: *const SessionViewState,
Expand Down Expand Up @@ -719,6 +773,7 @@ fn renderGridSessionCached(
term_rows: u16,
current_time_ms: i64,
theme: *const colors.Theme,
show_onboarding: bool,
) RenderError!void {
if (!session.spawned) {
cache_entry.presented_epoch = session.render_epoch;
Expand All @@ -735,7 +790,7 @@ fn renderGridSessionCached(
_ = c.SDL_SetRenderDrawColor(renderer, theme.background.r, theme.background.g, theme.background.b, 255);
_ = c.SDL_RenderClear(renderer);
const local_rect = Rect{ .x = 0, .y = 0, .w = rect.w, .h = rect.h };
try renderSessionContent(renderer, session, view, local_rect, scale, is_focused, font, term_cols, term_rows, theme);
try renderSessionContent(renderer, session, view, local_rect, scale, is_focused, font, term_cols, term_rows, theme, show_onboarding);
cache_entry.cache_epoch = session.render_epoch;
_ = c.SDL_SetRenderTarget(renderer, null);
}
Expand All @@ -756,11 +811,11 @@ fn renderGridSessionCached(
}

if (render_overlays) {
try renderSession(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme);
try renderSession(renderer, session, view, cache_entry, rect, scale, is_focused, apply_effects, font, term_cols, term_rows, current_time_ms, true, theme, show_onboarding);
return;
}

try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, theme);
try renderSessionContent(renderer, session, view, rect, scale, is_focused, font, term_cols, term_rows, theme, show_onboarding);
cache_entry.presented_epoch = session.render_epoch;
}

Expand Down