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
11 changes: 10 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,16 @@ Guidance for any code agent working on the Architect repo. Keep this file instru
## Coding Conventions
- Favor self-documenting code; keep comments minimal and meaningful.
- Default to ASCII unless the file already uses non-ASCII.
- Always handle errors explicitly: propagate, recover, or log; do not swallow errors with bare `catch {}` / `catch unreachable` unless proven impossible.
- **Error handling**: Always handle errors explicitly—propagate, recover, or log. Never use bare `catch {}` or `catch unreachable`. Even for "impossible" failures like action queue appends, log them:
```zig
// WRONG: silently swallows the error
actions.append(.SomeAction) catch {};

// CORRECT: log the error for debugging
actions.append(.SomeAction) catch |err| {
log.warn("failed to queue action: {}", .{err});
};
```
- Run `zig fmt src/` (or `zig fmt` on touched Zig files) before wrapping up changes.
- Avoid destructive git commands and do not revert user changes.

Expand Down
4 changes: 4 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ src/
├── colors.zig # Theme and color palette management (ANSI 16/256)
├── config.zig # TOML config persistence
├── geom.zig # Rect + point containment
├── metrics.zig # Metrics collection framework (glyph cache stats, frame count)
├── font.zig # Font rendering, glyph caching, HarfBuzz shaping
├── font_cache.zig # Shared font cache (terminal + UI)
├── font_paths.zig # Font path resolution for system fonts
Expand Down Expand Up @@ -128,6 +129,7 @@ src/
│ ├── help_overlay.zig # Keyboard shortcut overlay (? pill)
│ ├── hotkey_indicator.zig # Hotkey visual feedback indicator
│ ├── marquee_label.zig # Reusable scrolling text label
│ ├── metrics_overlay.zig # Metrics overlay (Cmd+Shift+M)
│ ├── pill_group.zig # Pill overlay coordinator (collapses others)
│ ├── quit_confirm.zig # Quit confirmation dialog
│ ├── restart_buttons.zig # Dead session restart buttons
Expand Down Expand Up @@ -208,6 +210,7 @@ union(enum) {
CreateWorktree: CreateWorktreeAction, // git worktree add .architect/<name> -b <name> && cd there
RemoveWorktree: RemoveWorktreeAction, // Remove a git worktree
DespawnSession: usize, // Despawn/kill a session at index
ToggleMetrics: void, // Toggle metrics overlay visibility
}
```

Expand Down Expand Up @@ -262,6 +265,7 @@ Components that consume events:
- `ConfirmDialogComponent`: Generic confirmation dialog (used by worktree removal, etc.)
- `PillGroupComponent`: Coordinates pill overlays (collapses one when another expands)
- `GlobalShortcutsComponent`: Handles global shortcuts like Cmd+, to open config
- `MetricsOverlayComponent`: Cmd+Shift+M to toggle metrics overlay (when enabled in config)

## Rendering Order

Expand Down
20 changes: 20 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,23 @@ vsync = true # Enable vertical sync (default: true)

Disabling vsync may reduce input latency but can cause screen tearing.

### Metrics Configuration

```toml
[metrics]
enabled = false # Enable metrics collection overlay (default: false)
```

When enabled, press `Cmd+Shift+M` to toggle the metrics overlay in the bottom-right corner. The overlay displays:
- **Frames**: Total rendered frame count
- **FPS**: Frames per second
- **Glyph cache**: Number of cached glyph textures
- **Glyph hits/s**: Glyph cache hits per second
- **Glyph misses/s**: Glyph cache misses per second
- **Glyph evictions/s**: Glyph cache evictions per second

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

### Complete Example

```toml
Expand Down Expand Up @@ -161,6 +178,9 @@ enable_animations = true

[rendering]
vsync = true

[metrics]
enabled = false
```

## persistence.toml
Expand Down
1 change: 1 addition & 0 deletions src/c.zig
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ pub const SDLK_KP_MINUS = c_import.SDLK_KP_MINUS;
pub const SDLK_A = c_import.SDLK_A;
pub const SDLK_C = c_import.SDLK_C;
pub const SDLK_K = c_import.SDLK_K;
pub const SDLK_M = c_import.SDLK_M;
pub const SDLK_N = c_import.SDLK_N;
pub const SDLK_O = c_import.SDLK_O;
pub const SDLK_T = c_import.SDLK_T;
Expand Down
9 changes: 9 additions & 0 deletions src/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ pub const Rendering = struct {
vsync: bool = true,
};

pub const MetricsConfig = struct {
enabled: bool = false,
};

pub const Persistence = struct {
const TerminalKeyPrefix = "terminal_";

Expand Down Expand Up @@ -437,6 +441,7 @@ pub const Config = struct {
theme: ThemeConfig = .{},
ui: UiConfig = .{},
rendering: Rendering = .{},
metrics: MetricsConfig = .{},

pub fn load(allocator: std.mem.Allocator) LoadError!Config {
const config_path = try getConfigPath(allocator);
Expand Down Expand Up @@ -513,6 +518,10 @@ pub const Config = struct {
\\# bright_cyan = "#56B6C2"
\\# bright_white = "#CDD6E0"
\\
\\# Metrics overlay (Cmd+Shift+M to toggle when enabled)
\\# [metrics]
\\# enabled = false
\\
;

const file = try fs.createFileAbsolute(config_path, .{ .truncate = true });
Expand Down
12 changes: 12 additions & 0 deletions src/font.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// efficiently at varying scales.
const std = @import("std");
const c = @import("c.zig");
const metrics_mod = @import("metrics.zig");

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

Expand Down Expand Up @@ -53,6 +54,7 @@ pub const Font = struct {
cell_width: c_int,
cell_height: c_int,
owns_fonts: bool,
metrics: ?*metrics_mod.Metrics = null,

/// Limit cached glyph textures to avoid unbounded GPU/heap growth.
const MAX_GLYPH_CACHE_ENTRIES: usize = 4096;
Expand Down Expand Up @@ -486,6 +488,7 @@ pub const Font = struct {

if (self.glyph_cache.getEntry(key)) |entry| {
entry.value_ptr.seq = self.nextSeq();
if (self.metrics) |m| m.increment(.glyph_cache_hits);
return entry.value_ptr.texture;
}

Expand Down Expand Up @@ -519,6 +522,10 @@ pub const Font = struct {
_ = c.SDL_SetTextureScaleMode(texture, c.SDL_SCALEMODE_LINEAR);

try self.glyph_cache.put(key, .{ .texture = texture, .seq = self.nextSeq() });
if (self.metrics) |m| {
m.increment(.glyph_cache_misses);
m.set(.glyph_cache_size, self.glyph_cache.count());
}
self.evictIfNeeded();
return texture;
}
Expand All @@ -534,10 +541,15 @@ pub const Font = struct {
if (findOldestKey(&self.glyph_cache)) |victim| {
if (self.glyph_cache.fetchRemove(victim)) |removed| {
c.SDL_DestroyTexture(removed.value.texture);
if (self.metrics) |m| m.increment(.glyph_cache_evictions);
}
}
}

// O(n) linear scan to find the oldest entry. A proper LRU list would give O(1) eviction,
// but in practice the 4096-entry cache is large enough that evictions are rare during
// normal terminal usage—even with diverse Unicode, emoji, and multiple font sizes.
// The simplicity of a flat hashmap outweighs the cost of occasional linear scans.
fn findOldestKey(map: *std.AutoHashMap(GlyphKey, CacheEntry)) ?GlyphKey {
var it = map.iterator();
var oldest_key: ?GlyphKey = null;
Expand Down
19 changes: 19 additions & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const ui_mod = @import("ui/mod.zig");
const font_cache_mod = @import("font_cache.zig");
const ghostty_vt = @import("ghostty-vt");
const c = @import("c.zig");
const metrics_mod = @import("metrics.zig");
const open_url = @import("os/open.zig");
const url_matcher = @import("url_matcher.zig");

Expand Down Expand Up @@ -255,8 +256,13 @@ pub fn main() !void {
font_paths.emoji_fallback,
);

var metrics_storage: metrics_mod.Metrics = metrics_mod.Metrics.init();
const metrics_ptr: ?*metrics_mod.Metrics = if (config.metrics.enabled) &metrics_storage else null;
metrics_mod.global = metrics_ptr;

var font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(font_size, ui_scale));
defer font.deinit();
font.metrics = metrics_ptr;

var ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(UI_FONT_SIZE, ui_scale));
defer ui_font.deinit();
Expand Down Expand Up @@ -381,6 +387,8 @@ pub fn main() !void {
try ui.register(global_shortcuts_component);
const cwd_bar_component = try ui_mod.cwd_bar.CwdBarComponent.init(allocator);
try ui.register(cwd_bar_component.asComponent());
const metrics_overlay_component = try ui_mod.metrics_overlay.MetricsOverlayComponent.init(allocator);
try ui.register(metrics_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 @@ -455,6 +463,7 @@ pub fn main() !void {
font.deinit();
ui_font.deinit();
font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(font_size, ui_scale));
font.metrics = metrics_ptr;
ui_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(UI_FONT_SIZE, ui_scale));
ui.assets.ui_font = &ui_font;
const new_term_size = calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows);
Expand Down Expand Up @@ -637,6 +646,7 @@ pub fn main() !void {
const new_font = try initSharedFont(allocator, renderer, &shared_font_cache, scaledFontSize(target_size, ui_scale));
font.deinit();
font = new_font;
font.metrics = metrics_ptr;
font_size = target_size;

const term_size = calculateTerminalSizeForMode(&font, render_width, render_height, anim_state.mode, config.grid.font_scale, grid_cols, grid_rows);
Expand Down Expand Up @@ -1296,6 +1306,14 @@ pub fn main() !void {
session.attention = false;
ui.showToast("Removing worktree…", now);
},
.ToggleMetrics => {
if (config.metrics.enabled) {
metrics_overlay_component.toggle();
if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘⇧M", now);
} else {
ui.showToast("Metrics disabled in config", now);
}
},
};

if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or
Expand Down Expand Up @@ -1353,6 +1371,7 @@ pub fn main() !void {
try renderer_mod.render(renderer, sessions, cell_width_pixels, cell_height_pixels, grid_cols, grid_rows, &anim_state, now, &font, full_cols, full_rows, render_width, render_height, &theme, config.grid.font_scale);
ui.render(&ui_render_host, renderer);
_ = c.SDL_RenderPresent(renderer);
metrics_mod.increment(.frame_count);
last_render_ns = std.time.nanoTimestamp();
}

Expand Down
99 changes: 99 additions & 0 deletions src/metrics.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
const std = @import("std");

pub const MetricKind = enum(u8) {
glyph_cache_hits,
glyph_cache_misses,
glyph_cache_evictions,
glyph_cache_size,
frame_count,
};

const METRIC_COUNT = @typeInfo(MetricKind).@"enum".fields.len;

pub const Metrics = struct {
values: [METRIC_COUNT]u64,
prev_values: [METRIC_COUNT]u64,
last_sample_ms: i64,

pub fn init() Metrics {
return .{
.values = [_]u64{0} ** METRIC_COUNT,
.prev_values = [_]u64{0} ** METRIC_COUNT,
.last_sample_ms = 0,
};
}

pub fn increment(self: *Metrics, kind: MetricKind) void {
self.values[@intFromEnum(kind)] +%= 1;
}

pub fn set(self: *Metrics, kind: MetricKind, value: u64) void {
self.values[@intFromEnum(kind)] = value;
}

pub fn get(self: *const Metrics, kind: MetricKind) u64 {
return self.values[@intFromEnum(kind)];
}

/// Returns elapsed time since last sample. Call getRate() after this,
/// then commitSample() to prepare for the next interval.
pub fn sampleRates(self: *const Metrics, now_ms: i64) i64 {
return now_ms - self.last_sample_ms;
}

/// Copies current values to prev_values for the next rate calculation.
/// Call this AFTER getRate() to avoid zeroing the delta.
pub fn commitSample(self: *Metrics, now_ms: i64) void {
@memcpy(&self.prev_values, &self.values);
self.last_sample_ms = now_ms;
}

pub fn getRate(self: *const Metrics, kind: MetricKind, elapsed_ms: i64) f64 {
if (elapsed_ms <= 0) return 0.0;
const idx = @intFromEnum(kind);
const delta = self.values[idx] -% self.prev_values[idx];
return @as(f64, @floatFromInt(delta)) / (@as(f64, @floatFromInt(elapsed_ms)) / 1000.0);
}
};

pub var global: ?*Metrics = null;

pub inline fn increment(kind: MetricKind) void {
if (global) |m| m.increment(kind);
}

pub inline fn set(kind: MetricKind, value: u64) void {
if (global) |m| m.set(kind, value);
}

test "Metrics.increment" {
var m = Metrics.init();
try std.testing.expectEqual(@as(u64, 0), m.get(.glyph_cache_hits));
m.increment(.glyph_cache_hits);
try std.testing.expectEqual(@as(u64, 1), m.get(.glyph_cache_hits));
m.increment(.glyph_cache_hits);
try std.testing.expectEqual(@as(u64, 2), m.get(.glyph_cache_hits));
}

test "Metrics.set" {
var m = Metrics.init();
m.set(.glyph_cache_size, 42);
try std.testing.expectEqual(@as(u64, 42), m.get(.glyph_cache_size));
m.set(.glyph_cache_size, 100);
try std.testing.expectEqual(@as(u64, 100), m.get(.glyph_cache_size));
}

test "Metrics.getRate" {
var m = Metrics.init();
m.last_sample_ms = 0;
m.prev_values = [_]u64{0} ** METRIC_COUNT;
m.values[@intFromEnum(MetricKind.glyph_cache_hits)] = 10;
const rate = m.getRate(.glyph_cache_hits, 1000);
try std.testing.expectApproxEqAbs(@as(f64, 10.0), rate, 0.001);
}

test "global metrics null check" {
global = null;
increment(.frame_count);
set(.glyph_cache_size, 100);
}
Loading