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
13 changes: 9 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Architect is a terminal multiplexer displaying interactive sessions in a grid wi
8. Calls `renderer.render` for the scene, then `ui.render` for overlays, then presents.
9. Sleeps based on idle/active frame targets (~16ms active, ~50ms idle).

`SessionState.dirty` is set on terminal updates and cleared after a successful render (cached grid or full view). When collapsing from full view to grid, the focused session is marked dirty so its cache refreshes before idle throttling resumes.
`SessionState.render_epoch` increments on terminal updates, and the renderer cache tracks the last presented/cache epochs. When collapsing from full view to grid, the focused session’s render epoch is bumped so its cache refreshes before idle throttling resumes.

**Terminal resizing**
- `applyTerminalResize` updates the PTY size first, then resizes the `ghostty-vt` terminal.
Expand Down Expand Up @@ -232,16 +232,21 @@ struct {
theme: *const Theme, // Active color theme
}
```
`UiHost` is rebuilt on demand using a shared, preallocated `SessionUiInfo` buffer; treat it as a transient snapshot and do not retain its slices across calls.
`focused_has_foreground_process` is populated from a short-lived cache in `main.zig` to avoid per-frame syscalls; UI code should treat it as a hint and action handlers should recheck directly when needed.

## Data & State Boundaries

| Layer | State Location | What it contains |
|-------|----------------|------------------|
| Scene | `src/session/state.zig` | PTY, terminal buffer, scroll position, CWD, cache texture |
| Scene | `src/session/state.zig` | PTY, terminal buffer, scroll position, CWD, render epoch |
| Scene | `src/app/app_state.zig` | ViewMode, animation rects, focused session index |
| Renderer | `src/render/renderer.zig` (`RenderCache`) | Per-session cache textures and last presented/cache epochs |
| UI | Component structs | Visibility flags, animation timers, cached textures |
| Shared | `UiHost` | Read-only snapshot passed each frame |

`SessionState` bumps its render epoch on content changes; the renderer compares this against `RenderCache` epochs to decide when to redraw and when to refresh cached textures.

**Key rule**: Scene code must not own UI state; UI state lives inside components.

## Input Routing
Expand Down Expand Up @@ -283,7 +288,7 @@ Each `SessionState` contains:
- `terminal`: ghostty-vt terminal state machine
- `stream`: VT stream wrapper for output processing
- `process_watcher`: xev-based async process exit detection
- `cache_texture`: Cached render for grid view (dirty flag optimization)
- `render_epoch`: Monotonic counter for renderer invalidation
- `pending_write`: Buffered stdin for non-blocking writes

Sessions are lazily spawned: only session 0 starts on launch; others spawn on first click/navigation.
Expand Down Expand Up @@ -363,7 +368,7 @@ Coordinates multiple pill overlays:

6. **Lazy spawning**: Sessions spawn on demand, not at startup (except session 0).

7. **Cache invalidation**: Set `session.dirty = true` after any terminal content change.
7. **Cache invalidation**: Call `session.markDirty()` after any terminal content change.

## Dependencies

Expand Down
16 changes: 4 additions & 12 deletions docs/engine_plan_correction.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,19 +103,11 @@ The UI framework refactor outlined in engine_plan.md has been **fully completed*
- Removed unused CWD-related constants and `input` import from `renderer.zig`
- Registered `CwdBarComponent` with `UiRoot` in `main.zig`

### 2. Cache Texture Still on SessionState ⚠️ LOW PRIORITY (NOT A DEVIATION)
### 2. Renderer-Owned Cache ✅ RESOLVED

**Location:** `src/session/state.zig`
**Location:** `src/render/renderer.zig` (`RenderCache`)

```zig
cache_texture: ?*c.SDL_Texture = null,
cache_w: c_int = 0,
cache_h: c_int = 0,
```

**Analysis:** This is used by `renderGridSessionCached` in renderer.zig for caching terminal content in grid view. This is arguably **scene state**, not UI state, because it caches the terminal cell content itself (not UI overlays). The plan's invariant was about UI state/textures, and this cache is for the terminal scene.

**Verdict:** Not a deviation - this is scene caching, not UI caching. No action needed.
**Change:** The grid render cache now lives in the renderer as a per-session cache table, and `SessionState` exposes a `render_epoch` counter for invalidation. This keeps render resources owned by the renderer while preserving scene/UI separation.

---

Expand Down Expand Up @@ -153,7 +145,7 @@ These align with Section 10's prediction: "Once the framework exists, these beco
### Future Considerations

1. **Document the scene vs UI texture distinction**
- `cache_texture` for terminal content = scene (OK on session)
- Terminal content caches = renderer-owned (`RenderCache`)
- Text/label textures for bars/overlays = UI (should be in components)

2. **Consider unifying pill overlays**
Expand Down
86 changes: 57 additions & 29 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const UI_FONT_SIZE: c_int = 18;
const ACTIVE_FRAME_NS: i128 = 16_666_667;
const IDLE_FRAME_NS: i128 = 50_000_000;
const MAX_IDLE_RENDER_GAP_NS: i128 = 250_000_000;
const FOREGROUND_PROCESS_CACHE_MS: i64 = 150;
const SessionStatus = app_state.SessionStatus;
const ViewMode = app_state.ViewMode;
const Rect = app_state.Rect;
Expand All @@ -51,6 +52,26 @@ const FontSizeDirection = input.FontSizeDirection;
const GridNavDirection = input.GridNavDirection;
const CursorKind = enum { arrow, ibeam, pointer };

const ForegroundProcessCache = struct {
session_idx: ?usize = null,
last_check_ms: i64 = 0,
value: bool = false,

fn get(self: *ForegroundProcessCache, now_ms: i64, focused_session: usize, sessions: []const SessionState) bool {
if (self.session_idx != focused_session) {
self.session_idx = focused_session;
self.last_check_ms = 0;
}
if (self.last_check_ms == 0 or now_ms < self.last_check_ms or
now_ms - self.last_check_ms >= FOREGROUND_PROCESS_CACHE_MS)
{
self.value = sessions[focused_session].hasForegroundProcess();
self.last_check_ms = now_ms;
}
return self.value;
}
};

const ImeComposition = struct {
codepoints: usize = 0,

Expand Down Expand Up @@ -336,6 +357,14 @@ pub fn main() !void {

init_count = sessions.len;

const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count);
defer allocator.free(session_ui_info);

var render_cache = try renderer_mod.RenderCache.init(allocator, grid_count);
defer render_cache.deinit();

var foreground_cache = ForegroundProcessCache{};

var running = true;

var anim_state = AnimationState{
Expand Down Expand Up @@ -419,8 +448,7 @@ pub fn main() !void {
}
processed_event = true;
var scaled_event = scaleEventToRender(&event, scale_x, scale_y);
const session_ui_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count);
defer allocator.free(session_ui_info);
const focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions);
const ui_host = makeUiHost(
now,
render_width,
Expand All @@ -433,6 +461,7 @@ pub fn main() !void {
&anim_state,
sessions,
session_ui_info,
focused_has_foreground_process,
&theme,
);

Expand Down Expand Up @@ -618,7 +647,7 @@ pub fn main() !void {
}
}
session.deinit(allocator);
session.dirty = true;
session.markDirty();
}
continue;
}
Expand Down Expand Up @@ -764,7 +793,7 @@ pub fn main() !void {
}
try navigateGrid(&anim_state, sessions, direction, now, true, false, grid_cols, grid_rows, &loop);
const new_session = anim_state.focused_session;
sessions[new_session].dirty = true;
sessions[new_session].markDirty();
std.debug.print("Grid nav to session {d} (with wrapping)\n", .{new_session});
} else if (anim_state.mode == .Full) {
if (config.ui.show_hotkey_feedback) {
Expand Down Expand Up @@ -962,26 +991,26 @@ pub fn main() !void {
focused.hovered_link_start = link_match.start_pin;
focused.hovered_link_end = link_match.end_pin;
allocator.free(link_match.url);
focused.dirty = true;
focused.markDirty();
} else {
desired_cursor = .ibeam;
focused.hovered_link_start = null;
focused.hovered_link_end = null;
focused.dirty = true;
focused.markDirty();
}
} else {
desired_cursor = .ibeam;
if (focused.hovered_link_start != null) {
focused.hovered_link_start = null;
focused.hovered_link_end = null;
focused.dirty = true;
focused.markDirty();
}
}
} else {
if (focused.hovered_link_start != null) {
focused.hovered_link_start = null;
focused.hovered_link_end = null;
focused.dirty = true;
focused.markDirty();
}
}
}
Expand Down Expand Up @@ -1080,17 +1109,16 @@ pub fn main() !void {

try loop.run(.no_wait);

var any_session_dirty = false;
var has_scroll_inertia = false;
for (sessions) |*session| {
session.checkAlive();
try session.processOutput();
try session.flushPendingWrites();
session.updateCwd(now);
updateScrollInertia(session, delta_time_s);
any_session_dirty = any_session_dirty or session.dirty;
has_scroll_inertia = has_scroll_inertia or (session.scroll_velocity != 0.0);
}
const any_session_dirty = render_cache.anyDirty(sessions);

var notifications = notify_queue.drainAll();
defer notifications.deinit(allocator);
Expand All @@ -1109,8 +1137,7 @@ pub fn main() !void {
}
}

const ui_update_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count);
defer allocator.free(ui_update_info);
var focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions);
const ui_update_host = makeUiHost(
now,
render_width,
Expand All @@ -1122,7 +1149,8 @@ pub fn main() !void {
grid_rows,
&anim_state,
sessions,
ui_update_info,
session_ui_info,
focused_has_foreground_process,
&theme,
);
ui.update(&ui_update_host);
Expand All @@ -1144,7 +1172,7 @@ pub fn main() !void {
}
}
sessions[idx].deinit(allocator);
sessions[idx].dirty = true;
sessions[idx].markDirty();
std.debug.print("UI requested despawn: {d}\n", .{idx});
}
},
Expand Down Expand Up @@ -1329,7 +1357,7 @@ pub fn main() !void {
};
anim_state.mode = next_mode;
if (previous_mode == .Collapsing and next_mode == .Grid and anim_state.focused_session < sessions.len) {
sessions[anim_state.focused_session].dirty = true;
sessions[anim_state.focused_session].markDirty();
}
std.debug.print("Animation complete, new mode: {s}\n", .{@tagName(anim_state.mode)});
}
Expand All @@ -1350,8 +1378,7 @@ pub fn main() !void {
});
}

const ui_render_info = try allocator.alloc(ui_mod.SessionUiInfo, grid_count);
defer allocator.free(ui_render_info);
focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions);
const ui_render_host = makeUiHost(
now,
render_width,
Expand All @@ -1363,7 +1390,8 @@ pub fn main() !void {
grid_rows,
&anim_state,
sessions,
ui_render_info,
session_ui_info,
focused_has_foreground_process,
&theme,
);

Expand All @@ -1373,7 +1401,7 @@ pub fn main() !void {
const should_render = animating or any_session_dirty or ui_needs_frame or processed_event or had_notifications or last_render_stale;

if (should_render) {
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);
try renderer_mod.render(renderer, &render_cache, 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);
Expand Down Expand Up @@ -1623,6 +1651,7 @@ fn makeUiHost(
anim_state: *const AnimationState,
sessions: []const SessionState,
buffer: []ui_mod.SessionUiInfo,
focused_has_foreground_process: bool,
theme: *const colors_mod.Theme,
) ui_mod.UiHost {
for (sessions, 0..) |session, i| {
Expand All @@ -1636,7 +1665,6 @@ fn makeUiHost(

const focused_session = &sessions[anim_state.focused_session];
const focused_cwd = focused_session.cwd_path;
const focused_has_foreground_process = focused_session.hasForegroundProcess();

return .{
.now_ms = now,
Expand Down Expand Up @@ -1700,7 +1728,7 @@ fn scrollSession(session: *SessionState, delta: isize, now: i64) void {
var pages = &terminal.screens.active.pages;
pages.scroll(.{ .delta_row = delta });
session.is_viewing_scrollback = (pages.viewport != .active);
session.dirty = true;
session.markDirty();
}

const sensitivity: f32 = 0.08;
Expand Down Expand Up @@ -1734,7 +1762,7 @@ fn updateScrollInertia(session: *SessionState, delta_time_s: f32) void {
var pages = &terminal.screens.active.pages;
pages.scroll(.{ .delta_row = scroll_lines });
session.is_viewing_scrollback = (pages.viewport != .active);
session.dirty = true;
session.markDirty();
}

session.scroll_remainder = scroll_amount - @as(f32, @floatFromInt(scroll_lines));
Expand Down Expand Up @@ -1832,7 +1860,7 @@ fn applyTerminalResize(
session.stream = vt_stream.initStream(allocator, terminal, shell);
}

session.dirty = true;
session.markDirty();
}
}
}
Expand Down Expand Up @@ -1948,7 +1976,7 @@ fn beginSelection(session: *SessionState, pin: ghostty_vt.Pin) void {
session.selection_anchor = pin;
session.selection_pending = true;
session.selection_dragging = false;
session.dirty = true;
session.markDirty();
}

fn startSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void {
Expand All @@ -1962,7 +1990,7 @@ fn startSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void {
terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| {
log.warn("session {d}: failed to start selection: {}", .{ session.id, err });
};
session.dirty = true;
session.markDirty();
}

fn updateSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void {
Expand All @@ -1972,7 +2000,7 @@ fn updateSelectionDrag(session: *SessionState, pin: ghostty_vt.Pin) void {
terminal.screens.active.select(ghostty_vt.Selection.init(anchor, pin, false)) catch |err| {
log.warn("session {d}: failed to update selection: {}", .{ session.id, err });
};
session.dirty = true;
session.markDirty();
}

fn endSelection(session: *SessionState) void {
Expand Down Expand Up @@ -2066,7 +2094,7 @@ fn selectWord(session: *SessionState, pin: ghostty_vt.Pin, is_viewing_scrollback
log.err("failed to select word: {}", .{err});
return;
};
session.dirty = true;
session.markDirty();
}

/// Select the entire line at the given pin position.
Expand Down Expand Up @@ -2102,7 +2130,7 @@ fn selectLine(session: *SessionState, pin: ghostty_vt.Pin, is_viewing_scrollback
log.err("failed to select line: {}", .{err});
return;
};
session.dirty = true;
session.markDirty();
}

const LinkMatch = struct {
Expand Down Expand Up @@ -2471,7 +2499,7 @@ fn clearTerminal(session: *SessionState) void {
terminal.screens.active.clearSelection();
terminal.eraseDisplay(ghostty_vt.EraseDisplay.scrollback, false);
terminal.eraseDisplay(ghostty_vt.EraseDisplay.complete, false);
session.dirty = true;
session.markDirty();

// Trigger shell redraw like Ghostty (FF) so the prompt is repainted at top.
session.sendInput(&[_]u8{0x0C}) catch |err| {
Expand Down
Loading