From 758ce80514265c2c2ffcb2c9cb907210e319712f Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sat, 7 Feb 2026 09:19:06 +0100 Subject: [PATCH 01/10] feat(ui): add inline review comments to diff overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: Users need a way to annotate diff lines with review comments and send them to AI coding agents running in Architect terminals, enabling a code-review-like workflow. Solution: Added click-to-comment on any diff line in the ⌘D overlay with inline editing boxes that push content down. Comments persist in `.architect/diff_comments.json` per repo and survive close/reopen. A green "Send to agent" button detects whether an agent is running via foreground process detection — pasting directly if so, or showing a dropdown to start claude/codex/gemini first. Also rewrote fillRoundedRect and drawRoundedBorder in primitives.zig to eliminate overdraw artifacts with semi-transparent colors. --- README.md | 1 + docs/architecture.md | 2 +- src/app/runtime.zig | 49 ++ src/app/ui_host.zig | 4 + src/gfx/primitives.zig | 114 ++- src/ui/components/diff_overlay.zig | 1125 +++++++++++++++++++++++++++- src/ui/types.zig | 8 + 7 files changed, 1220 insertions(+), 83 deletions(-) diff --git a/README.md b/README.md index 51848e3..356fcaa 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Architect solves this with a grid view that keeps all your agents visible, with - **Grid view** — keep all agents visible simultaneously, expand any one to full screen - **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 ### Terminal Essentials - Smooth animated transitions for grid expansion, contraction, and reflow (cells and borders move/resize together) diff --git a/docs/architecture.md b/docs/architecture.md index ef1db76..202df90 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -290,7 +290,7 @@ Components that consume events: - `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) -- `DiffOverlayComponent`: Cmd+D to toggle git diff overlay (grid and full view); runs a repo-wide `git diff` (staged + unstaged) for the focused session's working tree, shows a GitHub-style unified diff with collapsible file headers, line number gutters, and colored add/remove lines, and labels the title bar with the repo root folder; dismiss with close button or ESC +- `DiffOverlayComponent`: Cmd+D to toggle git diff overlay (grid and full view); runs a repo-wide `git diff` (staged + unstaged) for the focused session's working tree, shows a GitHub-style unified diff with collapsible file headers, line number gutters, and colored add/remove lines, and labels the title bar with the repo root folder; dismiss with close button or ESC. Supports inline review comments: click any diff line to open a comment box, submit to save. Comments persist in `.architect/diff_comments.json` relative to the repo root and survive close/reopen. A green "Send to agent" button appears when unsent comments exist; it detects whether an agent is running in the focused terminal and either sends comments directly or shows a dropdown to start claude/codex/gemini first ## Rendering Order diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 365c033..d51bcf7 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -579,6 +579,13 @@ pub fn run() !void { var relaunch_trace_frames: u8 = 0; var window_close_suppress_countdown: u8 = 0; + const PendingCommentSend = struct { + session: usize, + text: []const u8, + send_after_ms: i64, + }; + var pending_comment_send: ?PendingCommentSend = null; + const session_interaction_component = try ui_mod.SessionInteractionComponent.init(allocator, sessions, &font); try ui.register(session_interaction_component.asComponent()); @@ -689,6 +696,7 @@ pub fn run() !void { &anim_state, sessions, session_ui_info, + session_interaction_component.viewSlice(), focused_has_foreground_process, &theme, ); @@ -1410,6 +1418,18 @@ pub fn run() !void { std.debug.print("Session {d} (slot {d}) status -> {s}\n", .{ note.session, session_idx, @tagName(note.state) }); } + if (pending_comment_send) |pcs| { + if (now >= pcs.send_after_ms) { + if (pcs.session < sessions.len) { + sessions[pcs.session].sendInput(pcs.text) catch |err| { + log.warn("failed to send pending diff comments: {}", .{err}); + }; + } + allocator.free(pcs.text); + pending_comment_send = null; + } + } + var focused_has_foreground_process = foreground_cache.get(now, anim_state.focused_session, sessions); const ui_update_host = ui_host.makeUiHost( now, @@ -1425,6 +1445,7 @@ pub fn run() !void { &anim_state, sessions, session_ui_info, + session_interaction_component.viewSlice(), focused_has_foreground_process, &theme, ); @@ -1832,6 +1853,33 @@ pub fn run() !void { .opened => if (config.ui.show_hotkey_feedback) ui.showHotkey("⌘D", now), } }, + .SendDiffComments => |dc_action| { + if (dc_action.session >= sessions.len) { + allocator.free(dc_action.comments_text); + if (dc_action.agent_command) |cmd| allocator.free(cmd); + continue; + } + var dc_session = sessions[dc_action.session]; + if (dc_action.agent_command) |cmd| { + dc_session.sendInput(cmd) catch |err| { + log.warn("failed to send agent command: {}", .{err}); + allocator.free(dc_action.comments_text); + allocator.free(cmd); + continue; + }; + allocator.free(cmd); + pending_comment_send = .{ + .session = dc_action.session, + .text = dc_action.comments_text, + .send_after_ms = now + 2000, + }; + } else { + dc_session.sendInput(dc_action.comments_text) catch |err| { + log.warn("failed to send diff comments: {}", .{err}); + }; + allocator.free(dc_action.comments_text); + } + }, }; if (anim_state.mode == .Expanding or anim_state.mode == .Collapsing or @@ -1896,6 +1944,7 @@ pub fn run() !void { &anim_state, sessions, session_ui_info, + session_interaction_component.viewSlice(), focused_has_foreground_process, &theme, ); diff --git a/src/app/ui_host.zig b/src/app/ui_host.zig index b6bd329..e15e54c 100644 --- a/src/app/ui_host.zig +++ b/src/app/ui_host.zig @@ -3,10 +3,12 @@ const c = @import("../c.zig"); const colors_mod = @import("../colors.zig"); const session_state = @import("../session/state.zig"); const ui_mod = @import("../ui/mod.zig"); +const view_state = @import("../ui/session_view_state.zig"); const AnimationState = app_state.AnimationState; const Rect = app_state.Rect; const SessionState = session_state.SessionState; +const SessionViewState = view_state.SessionViewState; pub fn applyMouseContext(ui: *ui_mod.UiRoot, host: *ui_mod.UiHost, event: *const c.SDL_Event) void { switch (event.type) { @@ -60,6 +62,7 @@ pub fn makeUiHost( anim_state: *const AnimationState, sessions: []const *SessionState, buffer: []ui_mod.SessionUiInfo, + views: []const SessionViewState, focused_has_foreground_process: bool, theme: *const colors_mod.Theme, ) ui_mod.UiHost { @@ -69,6 +72,7 @@ pub fn makeUiHost( .spawned = session.spawned, .cwd_path = session.cwd_path, .cwd_basename = session.cwd_basename, + .session_status = if (i < views.len) views[i].status else .idle, }; } diff --git a/src/gfx/primitives.zig b/src/gfx/primitives.zig index 1436a61..cea5759 100644 --- a/src/gfx/primitives.zig +++ b/src/gfx/primitives.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const c = @import("../c.zig"); const geom = @import("../geom.zig"); @@ -8,32 +9,37 @@ pub fn drawRoundedBorder(renderer: *c.SDL_Renderer, rect: Rect, radius: c_int) v const fy = @as(f32, @floatFromInt(rect.y)); const fw = @as(f32, @floatFromInt(rect.w)); const fh = @as(f32, @floatFromInt(rect.h)); - const frad = @as(f32, @floatFromInt(radius)); + const clamped = @min(radius, @divFloor(@min(rect.w, rect.h), 2)); + const frad = @as(f32, @floatFromInt(clamped)); + // Straight edges _ = c.SDL_RenderLine(renderer, fx + frad, fy, fx + fw - frad - 1.0, fy); _ = c.SDL_RenderLine(renderer, fx + frad, fy + fh - 1.0, fx + fw - frad - 1.0, fy + fh - 1.0); _ = c.SDL_RenderLine(renderer, fx, fy + frad, fx, fy + fh - frad - 1.0); _ = c.SDL_RenderLine(renderer, fx + fw - 1.0, fy + frad, fx + fw - 1.0, fy + fh - frad - 1.0); - const centers = [_]struct { x: f32, y: f32, sx: f32, sy: f32 }{ - .{ .x = fx + frad, .y = fy + frad, .sx = -1.0, .sy = -1.0 }, - .{ .x = fx + fw - frad - 1.0, .y = fy + frad, .sx = 1.0, .sy = -1.0 }, - .{ .x = fx + frad, .y = fy + fh - frad - 1.0, .sx = -1.0, .sy = 1.0 }, - .{ .x = fx + fw - frad - 1.0, .y = fy + fh - frad - 1.0, .sx = 1.0, .sy = 1.0 }, + // Corner arcs using angle stepping with connected line segments + const corners = [_]struct { cx: f32, cy: f32, sx: f32, sy: f32 }{ + .{ .cx = fx + frad, .cy = fy + frad, .sx = -1.0, .sy = -1.0 }, + .{ .cx = fx + fw - frad - 1.0, .cy = fy + frad, .sx = 1.0, .sy = -1.0 }, + .{ .cx = fx + frad, .cy = fy + fh - frad - 1.0, .sx = -1.0, .sy = 1.0 }, + .{ .cx = fx + fw - frad - 1.0, .cy = fy + fh - frad - 1.0, .sx = 1.0, .sy = 1.0 }, }; - for (centers) |cinfo| { - var i: i32 = 0; - while (i <= radius) : (i += 1) { - const x = @as(f32, @floatFromInt(i)); - const y_sq = frad * frad - x * x; - if (y_sq >= 0) { - const y = @sqrt(y_sq); - _ = c.SDL_RenderPoint(renderer, cinfo.x + cinfo.sx * x, cinfo.y + cinfo.sy * y); - if (i > 0 and i < radius) { - _ = c.SDL_RenderPoint(renderer, cinfo.x + cinfo.sx * y, cinfo.y + cinfo.sy * x); - } - } + const steps: u32 = @max(8, @as(u32, @intCast(clamped)) * 4); + const half_pi = std.math.pi / 2.0; + + for (corners) |corner| { + var prev_x: f32 = corner.cx; + var prev_y: f32 = corner.cy + corner.sy * frad; + var i: u32 = 1; + while (i <= steps) : (i += 1) { + const angle = @as(f32, @floatFromInt(i)) / @as(f32, @floatFromInt(steps)) * half_pi; + const px = corner.cx + corner.sx * frad * @sin(angle); + const py = corner.cy + corner.sy * frad * @cos(angle); + _ = c.SDL_RenderLine(renderer, prev_x, prev_y, px, py); + prev_x = px; + prev_y = py; } } } @@ -70,51 +76,39 @@ pub fn fillRoundedRect(renderer: *c.SDL_Renderer, rect: Rect, radius: c_int) voi const fy = @as(f32, @floatFromInt(rect.y)); const fw = @as(f32, @floatFromInt(rect.w)); const fh = @as(f32, @floatFromInt(rect.h)); - const frad = @as(f32, @floatFromInt(radius)); - - const center_rect = c.SDL_FRect{ - .x = fx + frad, - .y = fy, - .w = fw - 2.0 * frad, - .h = fh, - }; - _ = c.SDL_RenderFillRect(renderer, ¢er_rect); - - const left_rect = c.SDL_FRect{ - .x = fx, - .y = fy + frad, - .w = frad, - .h = fh - 2.0 * frad, - }; - _ = c.SDL_RenderFillRect(renderer, &left_rect); + const clamped = @min(radius, @divFloor(@min(rect.w, rect.h), 2)); + const frad = @as(f32, @floatFromInt(clamped)); - const right_rect = c.SDL_FRect{ - .x = fx + fw - frad, - .y = fy + frad, - .w = frad, - .h = fh - 2.0 * frad, - }; - _ = c.SDL_RenderFillRect(renderer, &right_rect); + // Middle section (full width, between rounded corners) — drawn once, no overdraw + if (fh > 2.0 * frad) { + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = fx, + .y = fy + frad, + .w = fw, + .h = fh - 2.0 * frad, + }); + } - const corners = [_]struct { cx: f32, cy: f32 }{ - .{ .cx = fx + frad, .cy = fy + frad }, - .{ .cx = fx + fw - frad - 1.0, .cy = fy + frad }, - .{ .cx = fx + frad, .cy = fy + fh - frad - 1.0 }, - .{ .cx = fx + fw - frad - 1.0, .cy = fy + fh - frad - 1.0 }, - }; + // Top rounded section: one scanline per row, inset by arc + var y: c_int = 0; + while (y < clamped) : (y += 1) { + const dy = frad - @as(f32, @floatFromInt(y)) - 0.5; + const dx_sq = frad * frad - dy * dy; + if (dx_sq > 0) { + const dx = @sqrt(dx_sq); + _ = c.SDL_RenderLine(renderer, fx + frad - dx, fy + @as(f32, @floatFromInt(y)), fx + fw - frad + dx, fy + @as(f32, @floatFromInt(y))); + } + } - for (corners) |corner| { - var y: c_int = 0; - while (y < radius) : (y += 1) { - const fy_offset = @as(f32, @floatFromInt(y)); - const dist_sq = frad * frad - fy_offset * fy_offset; - if (dist_sq > 0) { - const dx = @sqrt(dist_sq); - _ = c.SDL_RenderLine(renderer, corner.cx - dx, corner.cy - fy_offset, corner.cx + dx, corner.cy - fy_offset); - if (y > 0) { - _ = c.SDL_RenderLine(renderer, corner.cx - dx, corner.cy + fy_offset, corner.cx + dx, corner.cy + fy_offset); - } - } + // Bottom rounded section: one scanline per row, inset by arc + const bottom_start: c_int = @max(clamped, rect.h - clamped); + var by: c_int = bottom_start; + while (by < rect.h) : (by += 1) { + const dy = @as(f32, @floatFromInt(by)) - (fh - frad) + 0.5; + const dx_sq = frad * frad - dy * dy; + if (dx_sq > 0) { + const dx = @sqrt(dx_sq); + _ = c.SDL_RenderLine(renderer, fx + frad - dx, fy + @as(f32, @floatFromInt(by)), fx + fw - frad + dx, fy + @as(f32, @floatFromInt(by))); } } } diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index ecad70a..6ec7093 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -48,6 +48,34 @@ const DisplayRow = struct { text_byte_offset: usize = 0, }; +const CommentKey = struct { + file_path: []const u8, + line_number: usize, +}; + +const DiffComment = struct { + key: CommentKey, + text: []const u8, + sent: bool, + display_row_index: ?usize, +}; + +const EditingComment = struct { + target_display_row: usize, + key: CommentKey, + input_buf: std.ArrayList(u8), + cursor_blink_start_ms: i64, + existing_index: ?usize, +}; + +const ClickTarget = union(enum) { + diff_row: usize, + comment_box: usize, + send_button: void, + dropdown_item: usize, + other: void, +}; + const SegmentKind = enum { file_path, hunk_header, @@ -107,12 +135,24 @@ pub const DiffOverlayComponent = struct { close_hovered: bool = false, hovered_file: ?usize = null, + comments: std.ArrayList(DiffComment) = .{}, + editing: ?EditingComment = null, + show_agent_dropdown: bool = false, + agent_dropdown_hovered: ?usize = null, + send_button_hovered: bool = false, + wrap_cols: usize = 0, + arrow_cursor: ?*c.SDL_Cursor = null, + pointer_cursor: ?*c.SDL_Cursor = null, + text_cursor: ?*c.SDL_Cursor = null, + current_cursor: CursorKind = .arrow, + animation_state: AnimationState = .closed, animation_start_ms: i64 = 0, render_alpha: f32 = 1.0, + const CursorKind = enum { arrow, pointer, text }; const AnimationState = enum { closed, opening, open, closing }; const animation_duration_ms: i64 = 250; const scale_from: f32 = 0.97; @@ -133,12 +173,26 @@ pub const DiffOverlayComponent = struct { const tab_display_width: usize = 4; const min_printable_char: u8 = 32; + const saved_comment_height: c_int = 32; + const editing_comment_height: c_int = 90; + const comment_input_height: c_int = 44; + const comment_button_height: c_int = 28; + const comment_button_width: c_int = 70; + const agent_dropdown_item_height: c_int = 28; + const agent_dropdown_width: c_int = 140; + const send_button_width: c_int = 110; + const send_button_height: c_int = 26; + const dropdown_items = [_][]const u8{ "Paste directly", "claude", "codex", "gemini" }; + // max_chars plus room for tab-to-spaces expansion const max_display_buffer: usize = 520; pub fn init(allocator: std.mem.Allocator) !*DiffOverlayComponent { const comp = try allocator.create(DiffOverlayComponent); comp.* = .{ .allocator = allocator }; + comp.arrow_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_DEFAULT); + comp.pointer_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_POINTER); + comp.text_cursor = c.SDL_CreateSystemCursor(c.SDL_SYSTEM_CURSOR_TEXT); return comp; } @@ -162,6 +216,8 @@ pub const DiffOverlayComponent = struct { } pub fn hide(self: *DiffOverlayComponent, now_ms: i64) void { + self.saveCommentsToFile(); + self.setCursor(.arrow); self.animation_state = .closing; self.animation_start_ms = now_ms; self.first_frame.markTransition(); @@ -295,6 +351,8 @@ pub const DiffOverlayComponent = struct { return .opened; }; self.parseDiffOutput(output); + self.loadCommentsFromFile(); + self.resolveCommentPositions(); return .opened; } @@ -701,6 +759,11 @@ pub const DiffOverlayComponent = struct { self.files.deinit(self.allocator); self.files = .{}; self.hovered_file = null; + self.freeComments(); + self.cancelEditing(); + self.show_agent_dropdown = false; + self.agent_dropdown_hovered = null; + self.send_button_hovered = false; if (self.last_repo_root) |root| { self.allocator.free(root); self.last_repo_root = null; @@ -911,9 +974,56 @@ pub const DiffOverlayComponent = struct { const key = event.key.key; const mod = event.key.mod; const has_gui = (mod & c.SDL_KMOD_GUI) != 0; + const has_shift = (mod & c.SDL_KMOD_SHIFT) != 0; const has_blocking = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_SHIFT)) != 0; + // Editing text input: handle special keys + if (self.editing != null) { + if (key == c.SDLK_ESCAPE) { + if (self.show_agent_dropdown) { + self.show_agent_dropdown = false; + } else { + self.cancelEditing(); + } + return true; + } + if (key == c.SDLK_RETURN or key == c.SDLK_RETURN2 or key == c.SDLK_KP_ENTER) { + if (has_shift) { + self.editing.?.input_buf.append(self.allocator, '\n') catch |err| { + log.warn("failed to append newline: {}", .{err}); + }; + self.editing.?.cursor_blink_start_ms = host.now_ms; + } else { + self.submitComment(); + } + return true; + } + if (key == c.SDLK_BACKSPACE) { + if (self.editing) |*ed| { + if (has_gui) { + ed.input_buf.clearRetainingCapacity(); + } else if (ed.input_buf.items.len > 0) { + // Remove last UTF-8 codepoint + var remove_len: usize = 1; + while (remove_len < ed.input_buf.items.len and + (ed.input_buf.items[ed.input_buf.items.len - remove_len] & 0xC0) == 0x80) + { + remove_len += 1; + } + ed.input_buf.shrinkRetainingCapacity(ed.input_buf.items.len - remove_len); + } + ed.cursor_blink_start_ms = host.now_ms; + } + return true; + } + return true; + } + if (key == c.SDLK_ESCAPE) { + if (self.show_agent_dropdown) { + self.show_agent_dropdown = false; + return true; + } actions.append(.ToggleDiffOverlay) catch |err| { log.warn("failed to queue ToggleDiffOverlay action: {}", .{err}); }; @@ -938,6 +1048,16 @@ pub const DiffOverlayComponent = struct { return true; }, + c.SDL_EVENT_TEXT_INPUT => { + if (self.editing) |*ed| { + const text = std.mem.span(event.text.text); + ed.input_buf.appendSlice(self.allocator, text) catch |err| { + log.warn("failed to append text input: {}", .{err}); + }; + ed.cursor_blink_start_ms = host.now_ms; + } + return true; + }, c.SDL_EVENT_MOUSE_WHEEL => { const wheel_y = event.wheel.y; self.scroll_offset = @max(0, self.scroll_offset - wheel_y * scroll_speed); @@ -948,6 +1068,28 @@ pub const DiffOverlayComponent = struct { const mouse_x: c_int = @intFromFloat(event.button.x); const mouse_y: c_int = @intFromFloat(event.button.y); + // Agent dropdown click + if (self.show_agent_dropdown) { + const dd = agentDropdownRect(host, overlayRect(host)); + if (geom.containsPoint(dd, mouse_x, mouse_y)) { + const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); + const rel_y = mouse_y - dd.y; + const item_idx: usize = @intCast(@divFloor(rel_y, item_h)); + if (item_idx < dropdown_items.len) { + if (item_idx == 0) { + // "Paste directly" — send to terminal without starting an agent + self.sendCommentsToAgent(host, actions, null); + } else { + self.sendCommentsToAgent(host, actions, dropdown_items[item_idx]); + } + } + self.show_agent_dropdown = false; + return true; + } + self.show_agent_dropdown = false; + return true; + } + const close_rect = closeButtonRect(host); if (geom.containsPoint(close_rect, mouse_x, mouse_y)) { actions.append(.ToggleDiffOverlay) catch |err| { @@ -956,6 +1098,57 @@ pub const DiffOverlayComponent = struct { return true; } + // Send to agent button + if (self.hasUnsentComments()) { + const sb = sendButtonRect(host, overlayRect(host)); + if (geom.containsPoint(sb, mouse_x, mouse_y)) { + if (host.focused_has_foreground_process) { + self.sendCommentsToAgent(host, actions, null); + } else { + self.show_agent_dropdown = true; + } + return true; + } + } + + // Comment editing button clicks + if (self.editing) |ed| { + const rect = overlayRect(host); + const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const scaled_line_h = self.lineHeight(host); + const total_h = dpi.scale(editing_comment_height, host.ui_scale); + const btn_h = dpi.scale(comment_button_height, host.ui_scale); + const btn_w = dpi.scale(comment_button_width, host.ui_scale); + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const scroll_int: c_int = @intFromFloat(self.scroll_offset); + const content_top = rect.y + scaled_title_h; + + const comment_y_base = self.computeRowY(ed.target_display_row, scaled_line_h, host.ui_scale) + scaled_line_h; + // Subtract any saved comment height at this row (editing comes after saved) + var saved_h: c_int = 0; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == ed.target_display_row) { + saved_h += dpi.scale(saved_comment_height, host.ui_scale); + } + } + } + const edit_y = content_top + comment_y_base + saved_h - scroll_int; + const btn_y = edit_y + total_h - btn_h - dpi.scale(6, host.ui_scale); + const submit_x = rect.x + rect.w - scaled_padding - btn_w * 2 - dpi.scale(12, host.ui_scale); + const cancel_x = submit_x + btn_w + dpi.scale(6, host.ui_scale); + + if (geom.containsPoint(.{ .x = submit_x, .y = btn_y, .w = btn_w, .h = btn_h }, mouse_x, mouse_y)) { + self.submitComment(); + return true; + } + if (geom.containsPoint(.{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, mouse_x, mouse_y)) { + self.cancelEditing(); + return true; + } + } + const rect = overlayRect(host); const scaled_title_h = dpi.scale(title_height, host.ui_scale); const scaled_line_h = self.lineHeight(host); @@ -965,15 +1158,49 @@ pub const DiffOverlayComponent = struct { if (mouse_y >= content_top and scaled_line_h > 0) { const relative_y = mouse_y - content_top + scroll_int; if (relative_y >= 0) { - const click_row: usize = @intCast(@divFloor(relative_y, scaled_line_h)); - if (click_row < self.display_rows.items.len) { - const row = self.display_rows.items[click_row]; - if (row.kind == .file_header) { - if (row.file_index) |file_idx| { - self.files.items[file_idx].collapsed = !self.files.items[file_idx].collapsed; - self.rebuildDisplayRows(); + const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale); + switch (target) { + .diff_row => |row_idx| { + const row = self.display_rows.items[row_idx]; + if (row.kind == .file_header) { + if (row.file_index) |file_idx| { + self.files.items[file_idx].collapsed = !self.files.items[file_idx].collapsed; + self.rebuildDisplayRows(); + self.resolveCommentPositions(); + } + } else if (row.kind == .diff_line and row.text_byte_offset == 0) { + self.openCommentForRow(row_idx, host.now_ms); } - } + }, + .comment_box => |row_idx| { + // Click on saved comment opens for editing + for (self.comments.items, 0..) |comment, ci| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_idx) { + // Open this comment for editing + self.cancelEditing(); + const key_dup = self.allocator.dupe(u8, comment.key.file_path) catch return true; + var input_buf = std.ArrayList(u8){}; + input_buf.appendSlice(self.allocator, comment.text) catch |err| { + log.warn("failed to copy comment: {}", .{err}); + self.allocator.free(key_dup); + return true; + }; + self.editing = EditingComment{ + .target_display_row = row_idx, + .key = .{ .file_path = key_dup, .line_number = comment.key.line_number }, + .input_buf = input_buf, + .cursor_blink_start_ms = host.now_ms, + .existing_index = ci, + }; + self.first_frame.markTransition(); + break; + } + } + } + }, + else => {}, } } } @@ -985,6 +1212,23 @@ pub const DiffOverlayComponent = struct { const mouse_y: c_int = @intFromFloat(event.motion.y); const close_rect = closeButtonRect(host); self.close_hovered = geom.containsPoint(close_rect, mouse_x, mouse_y); + self.send_button_hovered = if (self.hasUnsentComments()) + geom.containsPoint(sendButtonRect(host, overlayRect(host)), mouse_x, mouse_y) + else + false; + + // Agent dropdown hover + if (self.show_agent_dropdown) { + const dd = agentDropdownRect(host, overlayRect(host)); + if (geom.containsPoint(dd, mouse_x, mouse_y)) { + const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); + const rel_y = mouse_y - dd.y; + const idx: usize = @intCast(@divFloor(rel_y, item_h)); + self.agent_dropdown_hovered = if (idx < dropdown_items.len) idx else null; + } else { + self.agent_dropdown_hovered = null; + } + } const rect = overlayRect(host); const scaled_title_h = dpi.scale(title_height, host.ui_scale); @@ -993,22 +1237,37 @@ pub const DiffOverlayComponent = struct { const scroll_int: c_int = @intFromFloat(self.scroll_offset); self.hovered_file = null; - if (mouse_y >= content_top and scaled_line_h > 0) { + var want_cursor: CursorKind = .arrow; + if (self.close_hovered or self.send_button_hovered) { + want_cursor = .pointer; + } else if (self.show_agent_dropdown and self.agent_dropdown_hovered != null) { + want_cursor = .pointer; + } else if (mouse_y >= content_top and scaled_line_h > 0) { const relative_y = mouse_y - content_top + scroll_int; if (relative_y >= 0) { - const hover_row: usize = @intCast(@divFloor(relative_y, scaled_line_h)); - if (hover_row < self.display_rows.items.len) { - const row = self.display_rows.items[hover_row]; - if (row.kind == .file_header) { - self.hovered_file = row.file_index; - } + const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale); + switch (target) { + .diff_row => |row_idx| { + if (row_idx < self.display_rows.items.len) { + const row = self.display_rows.items[row_idx]; + if (row.kind == .file_header) { + self.hovered_file = row.file_index; + want_cursor = .pointer; + } else if (row.kind == .diff_line and row.text_byte_offset == 0) { + want_cursor = .pointer; + } + } + }, + .comment_box => want_cursor = .pointer, + else => {}, } } } + self.setCursor(want_cursor); return true; }, - c.SDL_EVENT_KEY_UP, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_TEXT_INPUT, c.SDL_EVENT_TEXT_EDITING => return true, + c.SDL_EVENT_KEY_UP, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_TEXT_EDITING => return true, else => return false, } } @@ -1073,7 +1332,8 @@ pub const DiffOverlayComponent = struct { const row_count_f: f32 = @floatFromInt(self.display_rows.items.len); const scaled_line_h_f: f32 = @floatFromInt(cache.line_height); - const content_height: f32 = row_count_f * scaled_line_h_f; + const total_comment_h: f32 = @floatFromInt(self.totalCommentPixelHeight(host)); + const content_height: f32 = row_count_f * scaled_line_h_f + total_comment_h; const viewport_height: f32 = @floatFromInt(rect.h - scaled_title_h); self.max_scroll = @max(0, content_height - viewport_height); self.scroll_offset = @min(self.max_scroll, self.scroll_offset); @@ -1101,6 +1361,7 @@ pub const DiffOverlayComponent = struct { @floatFromInt(rect.y + scaled_title_h), ); + self.renderSendButton(host, renderer, assets, rect); self.renderCloseButton(host, renderer, assets, rect, scaled_font_size); const content_clip = c.SDL_Rect{ @@ -1111,11 +1372,12 @@ pub const DiffOverlayComponent = struct { }; _ = c.SDL_SetRenderClipRect(renderer, &content_clip); - self.renderDiffContent(host, renderer, rect, scaled_title_h, scaled_padding, cache); + self.renderDiffContent(host, renderer, rect, scaled_title_h, scaled_padding, cache, assets); _ = c.SDL_SetRenderClipRect(renderer, null); self.renderScrollbar(host, renderer, rect, scaled_title_h, content_height, viewport_height); + self.renderAgentDropdown(host, renderer, assets, rect); self.first_frame.markDrawn(); } @@ -1185,6 +1447,7 @@ pub const DiffOverlayComponent = struct { if (new_wrap != self.wrap_cols and new_wrap > 0) { self.wrap_cols = new_wrap; self.rebuildDisplayRows(); + self.resolveCommentPositions(); } } @@ -1529,7 +1792,7 @@ pub const DiffOverlayComponent = struct { } } - fn renderDiffContent(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, rect: geom.Rect, title_h: c_int, padding: c_int, cache: *Cache) void { + fn renderDiffContent(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, rect: geom.Rect, title_h: c_int, padding: c_int, cache: *Cache, assets: *types.UiAssets) void { const alpha = self.render_alpha; const scroll_int: c_int = @intFromFloat(self.scroll_offset); const content_top = rect.y + title_h; @@ -1538,8 +1801,11 @@ pub const DiffOverlayComponent = struct { const row_height = cache.line_height; if (row_height <= 0 or content_h <= 0) return; - const first_visible: usize = @intCast(@divFloor(scroll_int, row_height)); - const visible_count: usize = @intCast(@divFloor(content_h, row_height) + 2); + // With comments, we can't simply divide to find first_visible + // Use conservative estimate: start from 0 if we have comments + const has_comments = self.comments.items.len > 0 or self.editing != null; + const first_visible: usize = if (has_comments) 0 else @intCast(@divFloor(scroll_int, row_height)); + const visible_count: usize = if (has_comments) self.display_rows.items.len else @intCast(@divFloor(content_h, row_height) + 2); const scaled_gutter_w = dpi.scale(gutter_width, host.ui_scale); const scaled_chevron_sz = dpi.scale(chevron_size, host.ui_scale); @@ -1553,7 +1819,18 @@ pub const DiffOverlayComponent = struct { var row_index: usize = first_visible; while (row_index < end_row) : (row_index += 1) { const row = self.display_rows.items[row_index]; - const y_pos = content_top + @as(c_int, @intCast(row_index)) * row_height - scroll_int; + const y_pos = if (has_comments) + content_top + self.computeRowY(row_index, row_height, host.ui_scale) - scroll_int + else + content_top + @as(c_int, @intCast(row_index)) * row_height - scroll_int; + + // Skip rows that are above the viewport + if (y_pos + row_height < content_top) { + // But still need to render any comments attached to this row + continue; + } + // Skip rows that are below the viewport + if (y_pos > content_top + content_h) break; switch (row.kind) { .file_header => { @@ -1694,6 +1971,23 @@ pub const DiffOverlayComponent = struct { .h = @floatFromInt(render_h), }); } + + // Render comments attached to this row + var comment_y = y_pos + row_height; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_index) { + self.renderSavedComment(host, renderer, assets, rect, comment_y, comment); + comment_y += dpi.scale(saved_comment_height, host.ui_scale); + } + } + } + if (self.editing) |_| { + if (self.editing.?.target_display_row == row_index) { + self.renderEditingComment(host, renderer, assets, rect, comment_y); + } + } } } @@ -1755,10 +2049,797 @@ pub const DiffOverlayComponent = struct { }); } + // --- Comment management --- + + fn freeComments(self: *DiffOverlayComponent) void { + for (self.comments.items) |comment| { + self.allocator.free(comment.key.file_path); + self.allocator.free(comment.text); + } + self.comments.deinit(self.allocator); + self.comments = .{}; + } + + fn cancelEditing(self: *DiffOverlayComponent) void { + if (self.editing) |*ed| { + ed.input_buf.deinit(self.allocator); + self.allocator.free(ed.key.file_path); + self.editing = null; + } + } + + fn commentKeyForRow(self: *DiffOverlayComponent, row_index: usize) ?CommentKey { + if (row_index >= self.display_rows.items.len) return null; + const row = self.display_rows.items[row_index]; + if (row.kind != .diff_line) return null; + if (row.text_byte_offset != 0) return null; + + const file_idx = row.file_index orelse return null; + const hunk_idx = row.hunk_index orelse return null; + const line_idx = row.line_index orelse return null; + + if (file_idx >= self.files.items.len) return null; + const file = &self.files.items[file_idx]; + if (hunk_idx >= file.hunks.items.len) return null; + const hunk = &file.hunks.items[hunk_idx]; + if (line_idx >= hunk.lines.items.len) return null; + const hunk_line = &hunk.lines.items[line_idx]; + + const line_num = switch (hunk_line.kind) { + .add, .context => hunk_line.new_line orelse return null, + .remove => hunk_line.old_line orelse return null, + }; + + const file_path = self.allocator.dupe(u8, file.path) catch return null; + return CommentKey{ + .file_path = file_path, + .line_number = line_num, + }; + } + + fn findCommentIndex(self: *DiffOverlayComponent, key: CommentKey) ?usize { + for (self.comments.items, 0..) |comment, i| { + if (comment.sent) continue; + if (comment.key.line_number == key.line_number and std.mem.eql(u8, comment.key.file_path, key.file_path)) { + return i; + } + } + return null; + } + + fn lastWrappedRowForLine(self: *DiffOverlayComponent, row_index: usize) usize { + const row = self.display_rows.items[row_index]; + if (row.kind != .diff_line) return row_index; + const fi = row.file_index orelse return row_index; + const hi = row.hunk_index orelse return row_index; + const li = row.line_index orelse return row_index; + + var last = row_index; + var i = row_index + 1; + while (i < self.display_rows.items.len) : (i += 1) { + const r = self.display_rows.items[i]; + if (r.kind != .diff_line) break; + if (r.file_index != fi or r.hunk_index != hi or r.line_index != li) break; + last = i; + } + return last; + } + + fn openCommentForRow(self: *DiffOverlayComponent, row_index: usize, now_ms: i64) void { + const key = self.commentKeyForRow(row_index) orelse return; + const attach_row = self.lastWrappedRowForLine(row_index); + + if (self.editing) |*existing| { + if (existing.target_display_row == attach_row) { + self.allocator.free(key.file_path); + return; + } + self.cancelEditing(); + } + + const existing_idx = self.findCommentIndex(key); + var input_buf = std.ArrayList(u8){}; + if (existing_idx) |idx| { + input_buf.appendSlice(self.allocator, self.comments.items[idx].text) catch |err| { + log.warn("failed to copy comment text: {}", .{err}); + }; + } + + self.editing = EditingComment{ + .target_display_row = attach_row, + .key = key, + .input_buf = input_buf, + .cursor_blink_start_ms = now_ms, + .existing_index = existing_idx, + }; + self.first_frame.markTransition(); + } + + fn submitComment(self: *DiffOverlayComponent) void { + const ed = &(self.editing orelse return); + if (ed.input_buf.items.len == 0) { + if (ed.existing_index) |idx| { + self.removeComment(idx); + } + self.cancelEditing(); + self.saveCommentsToFile(); + return; + } + + const text = self.allocator.dupe(u8, ed.input_buf.items) catch |err| { + log.warn("failed to dupe comment text: {}", .{err}); + return; + }; + const file_path = self.allocator.dupe(u8, ed.key.file_path) catch |err| { + log.warn("failed to dupe comment path: {}", .{err}); + self.allocator.free(text); + return; + }; + + if (ed.existing_index) |idx| { + self.allocator.free(self.comments.items[idx].text); + self.comments.items[idx].text = text; + self.allocator.free(file_path); + } else { + self.comments.append(self.allocator, DiffComment{ + .key = .{ .file_path = file_path, .line_number = ed.key.line_number }, + .text = text, + .sent = false, + .display_row_index = ed.target_display_row, + }) catch |err| { + log.warn("failed to append comment: {}", .{err}); + self.allocator.free(text); + self.allocator.free(file_path); + return; + }; + } + + self.cancelEditing(); + self.destroyCache(); + self.saveCommentsToFile(); + } + + fn removeComment(self: *DiffOverlayComponent, idx: usize) void { + const comment = self.comments.items[idx]; + self.allocator.free(comment.key.file_path); + self.allocator.free(comment.text); + _ = self.comments.orderedRemove(idx); + } + + fn setCursor(self: *DiffOverlayComponent, kind: CursorKind) void { + if (self.current_cursor == kind) return; + self.current_cursor = kind; + const cursor = switch (kind) { + .arrow => self.arrow_cursor, + .pointer => self.pointer_cursor, + .text => self.text_cursor, + }; + if (cursor) |cur| _ = c.SDL_SetCursor(cur); + } + + fn hasUnsentComments(self: *DiffOverlayComponent) bool { + for (self.comments.items) |comment| { + if (!comment.sent) return true; + } + return false; + } + + fn resolveCommentPositions(self: *DiffOverlayComponent) void { + for (self.comments.items) |*comment| { + comment.display_row_index = null; + if (comment.sent) continue; + + for (self.display_rows.items, 0..) |row, i| { + if (row.kind != .diff_line) continue; + if (row.text_byte_offset != 0) continue; + const fi = row.file_index orelse continue; + const hi = row.hunk_index orelse continue; + const li = row.line_index orelse continue; + + if (fi >= self.files.items.len) continue; + const file = &self.files.items[fi]; + if (hi >= file.hunks.items.len) continue; + const hunk = &file.hunks.items[hi]; + if (li >= hunk.lines.items.len) continue; + const hunk_line = &hunk.lines.items[li]; + + const line_num = switch (hunk_line.kind) { + .add, .context => hunk_line.new_line orelse continue, + .remove => hunk_line.old_line orelse continue, + }; + + if (line_num == comment.key.line_number and std.mem.eql(u8, file.path, comment.key.file_path)) { + comment.display_row_index = self.lastWrappedRowForLine(i); + break; + } + } + } + } + + fn commentHeightAtRow(self: *DiffOverlayComponent, row_index: usize, ui_scale: f32) c_int { + var h: c_int = 0; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_index) { + h += dpi.scale(saved_comment_height, ui_scale); + } + } + } + if (self.editing) |ed| { + if (ed.target_display_row == row_index) { + h += dpi.scale(editing_comment_height, ui_scale); + } + } + return h; + } + + fn totalCommentPixelHeight(self: *DiffOverlayComponent, host: *const types.UiHost) c_int { + var total: c_int = 0; + for (self.comments.items) |comment| { + if (!comment.sent and comment.display_row_index != null) { + total += dpi.scale(saved_comment_height, host.ui_scale); + } + } + if (self.editing) |_| { + total += dpi.scale(editing_comment_height, host.ui_scale); + } + return total; + } + + fn computeRowY(self: *DiffOverlayComponent, row_index: usize, row_height: c_int, ui_scale: f32) c_int { + var y: c_int = @as(c_int, @intCast(row_index)) * row_height; + var i: usize = 0; + while (i < row_index) : (i += 1) { + y += self.commentHeightAtRow(i, ui_scale); + } + return y; + } + + fn resolveClickTarget(self: *DiffOverlayComponent, relative_y: c_int, row_height: c_int, ui_scale: f32) ClickTarget { + if (row_height <= 0) return .{ .other = {} }; + var cumulative_y: c_int = 0; + for (self.display_rows.items, 0..) |_, i| { + const row_start = cumulative_y; + const row_end = cumulative_y + row_height; + if (relative_y >= row_start and relative_y < row_end) { + return .{ .diff_row = i }; + } + cumulative_y = row_end; + const comment_h = self.commentHeightAtRow(i, ui_scale); + if (comment_h > 0 and relative_y >= cumulative_y and relative_y < cumulative_y + comment_h) { + return .{ .comment_box = i }; + } + cumulative_y += comment_h; + } + return .{ .other = {} }; + } + + fn formatCommentsForAgent(self: *DiffOverlayComponent) ?[]const u8 { + var buf = std.ArrayList(u8){}; + var first = true; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (!first) { + buf.appendSlice(self.allocator, "\n\n") catch return null; + } + buf.appendSlice(self.allocator, comment.key.file_path) catch return null; + buf.append(self.allocator, ':') catch return null; + var num_buf: [20]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{comment.key.line_number}) catch return null; + buf.appendSlice(self.allocator, num_str) catch return null; + buf.appendSlice(self.allocator, ": ") catch return null; + buf.appendSlice(self.allocator, comment.text) catch return null; + first = false; + } + buf.append(self.allocator, '\n') catch return null; + return buf.toOwnedSlice(self.allocator) catch |err| { + log.warn("failed to format comments: {}", .{err}); + return null; + }; + } + + fn sendCommentsToAgent(self: *DiffOverlayComponent, host: *const types.UiHost, actions: *types.UiActionQueue, agent_name: ?[]const u8) void { + const comments_text = self.formatCommentsForAgent() orelse return; + var agent_cmd: ?[]const u8 = null; + if (agent_name) |name| { + agent_cmd = std.fmt.allocPrint(self.allocator, "{s}\n", .{name}) catch { + self.allocator.free(comments_text); + return; + }; + } + actions.append(.{ .SendDiffComments = .{ + .session = host.focused_session, + .comments_text = comments_text, + .agent_command = agent_cmd, + } }) catch |err| { + log.warn("failed to queue SendDiffComments action: {}", .{err}); + self.allocator.free(comments_text); + if (agent_cmd) |cmd| self.allocator.free(cmd); + return; + }; + self.markCommentsSent(); + // Close the diff overlay after sending + actions.append(.ToggleDiffOverlay) catch |err| { + log.warn("failed to queue ToggleDiffOverlay action: {}", .{err}); + }; + } + + fn markCommentsSent(self: *DiffOverlayComponent) void { + for (self.comments.items) |*comment| { + comment.sent = true; + comment.display_row_index = null; + } + self.destroyCache(); + self.saveCommentsToFile(); + } + + fn sendButtonRect(host: *const types.UiHost, overlay_rect: geom.Rect) geom.Rect { + const btn_w = dpi.scale(send_button_width, host.ui_scale); + const btn_h = dpi.scale(send_button_height, host.ui_scale); + const btn_margin = dpi.scale(close_btn_margin, host.ui_scale); + const close_w = dpi.scale(close_btn_size, host.ui_scale); + return geom.Rect{ + .x = overlay_rect.x + overlay_rect.w - close_w - btn_margin * 2 - btn_w, + .y = overlay_rect.y + btn_margin, + .w = btn_w, + .h = btn_h, + }; + } + + fn agentDropdownRect(host: *const types.UiHost, overlay_rect: geom.Rect) geom.Rect { + const sb = sendButtonRect(host, overlay_rect); + const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); + const dd_w = dpi.scale(agent_dropdown_width, host.ui_scale); + return geom.Rect{ + .x = sb.x + sb.w - dd_w, + .y = sb.y + sb.h + dpi.scale(2, host.ui_scale), + .w = dd_w, + .h = item_h * @as(c_int, @intCast(dropdown_items.len)), + }; + } + + // --- Persistence --- + + fn saveCommentsToFile(self: *DiffOverlayComponent) void { + const repo_root = self.last_repo_root orelse return; + + const dir_path = std.fs.path.join(self.allocator, &.{ repo_root, ".architect" }) catch return; + defer self.allocator.free(dir_path); + std.fs.makeDirAbsolute(dir_path) catch |err| switch (err) { + error.PathAlreadyExists => {}, + else => { + log.warn("failed to create .architect dir: {}", .{err}); + return; + }, + }; + + const file_path = std.fs.path.join(self.allocator, &.{ dir_path, "diff_comments.json" }) catch return; + defer self.allocator.free(file_path); + + var buf = std.ArrayList(u8){}; + defer buf.deinit(self.allocator); + buf.appendSlice(self.allocator, "[\n") catch return; + var first = true; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (!first) buf.appendSlice(self.allocator, ",\n") catch return; + buf.appendSlice(self.allocator, " {\"file\":\"") catch return; + self.appendJsonEscaped(&buf, comment.key.file_path); + buf.appendSlice(self.allocator, "\",\"line\":") catch return; + var num_buf: [20]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d}", .{comment.key.line_number}) catch return; + buf.appendSlice(self.allocator, num_str) catch return; + buf.appendSlice(self.allocator, ",\"text\":\"") catch return; + self.appendJsonEscaped(&buf, comment.text); + buf.appendSlice(self.allocator, "\"}") catch return; + first = false; + } + buf.appendSlice(self.allocator, "\n]\n") catch return; + + const file = std.fs.createFileAbsolute(file_path, .{ .truncate = true }) catch |err| { + log.warn("failed to create comments file: {}", .{err}); + return; + }; + defer file.close(); + file.writeAll(buf.items) catch |err| { + log.warn("failed to write comments file: {}", .{err}); + }; + } + + fn appendJsonEscaped(self: *DiffOverlayComponent, buf: *std.ArrayList(u8), text: []const u8) void { + for (text) |ch| { + switch (ch) { + '"' => buf.appendSlice(self.allocator, "\\\"") catch return, + '\\' => buf.appendSlice(self.allocator, "\\\\") catch return, + '\n' => buf.appendSlice(self.allocator, "\\n") catch return, + '\r' => buf.appendSlice(self.allocator, "\\r") catch return, + '\t' => buf.appendSlice(self.allocator, "\\t") catch return, + else => buf.append(self.allocator, ch) catch return, + } + } + } + + fn loadCommentsFromFile(self: *DiffOverlayComponent) void { + const repo_root = self.last_repo_root orelse return; + const file_path = std.fs.path.join(self.allocator, &.{ repo_root, ".architect", "diff_comments.json" }) catch return; + defer self.allocator.free(file_path); + + const file = std.fs.openFileAbsolute(file_path, .{}) catch return; + defer file.close(); + const content = file.readToEndAlloc(self.allocator, 1024 * 1024) catch return; + defer self.allocator.free(content); + + self.parseCommentsJson(content); + } + + fn parseCommentsJson(self: *DiffOverlayComponent, content: []const u8) void { + var pos: usize = 0; + // Skip to '[' + while (pos < content.len and content[pos] != '[') pos += 1; + if (pos >= content.len) return; + pos += 1; + + while (pos < content.len) { + // Skip whitespace and commas + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\n' or content[pos] == '\r' or content[pos] == '\t' or content[pos] == ',')) pos += 1; + if (pos >= content.len or content[pos] == ']') break; + if (content[pos] != '{') break; + pos += 1; + + var file_str: ?[]const u8 = null; + var line_num: usize = 0; + var text_str: ?[]const u8 = null; + + // Parse object fields + while (pos < content.len and content[pos] != '}') { + while (pos < content.len and content[pos] != '"' and content[pos] != '}') pos += 1; + if (pos >= content.len or content[pos] == '}') break; + pos += 1; // skip opening quote + const key_start = pos; + while (pos < content.len and content[pos] != '"') pos += 1; + const key = content[key_start..pos]; + if (pos < content.len) pos += 1; // skip closing quote + // skip colon + while (pos < content.len and content[pos] != ':') pos += 1; + if (pos < content.len) pos += 1; + // skip whitespace + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t')) pos += 1; + + if (std.mem.eql(u8, key, "file") or std.mem.eql(u8, key, "text")) { + if (pos < content.len and content[pos] == '"') { + pos += 1; + const val = self.parseJsonString(content, &pos) orelse continue; + if (std.mem.eql(u8, key, "file")) { + if (file_str) |old| self.allocator.free(old); + file_str = val; + } else { + if (text_str) |old| self.allocator.free(old); + text_str = val; + } + } + } else if (std.mem.eql(u8, key, "line")) { + var n: usize = 0; + while (pos < content.len and content[pos] >= '0' and content[pos] <= '9') { + n = n * 10 + @as(usize, content[pos] - '0'); + pos += 1; + } + line_num = n; + } + } + if (pos < content.len and content[pos] == '}') pos += 1; + + if (file_str) |fp| { + if (text_str) |txt| { + self.comments.append(self.allocator, DiffComment{ + .key = .{ .file_path = fp, .line_number = line_num }, + .text = txt, + .sent = false, + .display_row_index = null, + }) catch |err| { + log.warn("failed to load comment: {}", .{err}); + self.allocator.free(fp); + self.allocator.free(txt); + }; + continue; + } + } + if (file_str) |fp| self.allocator.free(fp); + if (text_str) |txt| self.allocator.free(txt); + } + } + + fn parseJsonString(self: *DiffOverlayComponent, content: []const u8, pos: *usize) ?[]const u8 { + var buf = std.ArrayList(u8){}; + while (pos.* < content.len and content[pos.*] != '"') { + if (content[pos.*] == '\\' and pos.* + 1 < content.len) { + pos.* += 1; + switch (content[pos.*]) { + 'n' => buf.append(self.allocator, '\n') catch return null, + 'r' => buf.append(self.allocator, '\r') catch return null, + 't' => buf.append(self.allocator, '\t') catch return null, + '"' => buf.append(self.allocator, '"') catch return null, + '\\' => buf.append(self.allocator, '\\') catch return null, + else => buf.append(self.allocator, content[pos.*]) catch return null, + } + } else { + buf.append(self.allocator, content[pos.*]) catch return null; + } + pos.* += 1; + } + if (pos.* < content.len) pos.* += 1; // skip closing quote + return buf.toOwnedSlice(self.allocator) catch |err| { + log.warn("failed to parse JSON string: {}", .{err}); + return null; + }; + } + + // --- Comment rendering --- + + fn renderSavedComment(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment) void { + const comment_h = dpi.scale(saved_comment_height, host.ui_scale); + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const accent_w = dpi.scale(4, host.ui_scale); + const alpha = self.render_alpha; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background — more visible amber/orange tint + _ = c.SDL_SetRenderDrawColor(renderer, 180, 140, 40, @intFromFloat(50.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(comment_h), + }); + + // Left accent bar — thick amber + _ = c.SDL_SetRenderDrawColor(renderer, 220, 170, 50, @intFromFloat(220.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(accent_w), + .h = @floatFromInt(comment_h), + }); + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + // Render comment text in warm yellow/amber color + const comment_color = c.SDL_Color{ .r = 230, .g = 200, .b = 110, .a = 255 }; + const display_text = if (comment.text.len > 200) comment.text[0..200] else comment.text; + const tex = self.makeTextTexture(renderer, fonts.regular, display_text, comment_color) catch return; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_x = rect.x + scaled_padding + accent_w + dpi.scale(4, host.ui_scale); + const text_y = y_pos + @divFloor(comment_h - tex.h, 2); + const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale); + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } + + fn renderEditingComment(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int) void { + const ed = self.editing orelse return; + const total_h = dpi.scale(editing_comment_height, host.ui_scale); + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const input_h = dpi.scale(comment_input_height, host.ui_scale); + const btn_h = dpi.scale(comment_button_height, host.ui_scale); + const btn_w = dpi.scale(comment_button_width, host.ui_scale); + const alpha = self.render_alpha; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background + _ = c.SDL_SetRenderDrawColor(renderer, 40, 44, 52, @intFromFloat(230.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(total_h), + }); + + // Left accent bar + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(200.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(dpi.scale(3, host.ui_scale)), + .h = @floatFromInt(total_h), + }); + + // Input area + const input_x = rect.x + scaled_padding + dpi.scale(6, host.ui_scale); + const input_y = y_pos + dpi.scale(4, host.ui_scale); + const input_w = rect.w - scaled_padding * 2 - dpi.scale(12, host.ui_scale); + + _ = c.SDL_SetRenderDrawColor(renderer, 30, 33, 40, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + // Input border + const accent = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, @intFromFloat(100.0 * alpha)); + _ = c.SDL_RenderRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + // Render input text + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + if (ed.input_buf.items.len > 0) { + const display_text = if (ed.input_buf.items.len > 500) ed.input_buf.items[0..500] else ed.input_buf.items; + const tex = self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground) catch return; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_y = input_y + dpi.scale(4, host.ui_scale); + const max_text_w = input_w - dpi.scale(8, host.ui_scale); + const render_w = @min(tex.w, max_text_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(input_x + dpi.scale(4, host.ui_scale)), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } + + // Blinking cursor + const blink_ms = host.now_ms - ed.cursor_blink_start_ms; + const show_cursor = @mod(@divFloor(blink_ms, 500), 2) == 0; + if (show_cursor) { + var cursor_x = input_x + dpi.scale(4, host.ui_scale); + if (ed.input_buf.items.len > 0) { + const display_text = if (ed.input_buf.items.len > 500) ed.input_buf.items[0..500] else ed.input_buf.items; + if (self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground)) |tex| { + cursor_x += tex.w; + c.SDL_DestroyTexture(tex.tex); + } else |_| {} + } + const cursor_top = input_y + dpi.scale(4, host.ui_scale); + const cursor_h = scaled_font_size + dpi.scale(4, host.ui_scale); + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(200.0 * alpha)); + _ = c.SDL_RenderLine( + renderer, + @floatFromInt(cursor_x), + @floatFromInt(cursor_top), + @floatFromInt(cursor_x), + @floatFromInt(cursor_top + cursor_h), + ); + } + + // Submit button + const btn_y = y_pos + total_h - btn_h - dpi.scale(6, host.ui_scale); + const submit_x = rect.x + rect.w - scaled_padding - btn_w * 2 - dpi.scale(12, host.ui_scale); + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(220.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = submit_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + const submit_tex = self.makeTextTexture(renderer, fonts.regular, "Submit", .{ .r = 255, .g = 255, .b = 255, .a = 255 }) catch return; + defer c.SDL_DestroyTexture(submit_tex.tex); + _ = c.SDL_SetTextureAlphaMod(submit_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, submit_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(submit_x + @divFloor(btn_w - submit_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - submit_tex.h, 2)), + .w = @floatFromInt(submit_tex.w), + .h = @floatFromInt(submit_tex.h), + }); + + // Cancel button + const cancel_x = submit_x + btn_w + dpi.scale(6, host.ui_scale); + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(40.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(80.0 * alpha)); + primitives.drawRoundedBorder(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + const cancel_tex = self.makeTextTexture(renderer, fonts.regular, "Cancel", host.theme.foreground) catch return; + defer c.SDL_DestroyTexture(cancel_tex.tex); + _ = c.SDL_SetTextureAlphaMod(cancel_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, cancel_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(cancel_x + @divFloor(btn_w - cancel_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - cancel_tex.h, 2)), + .w = @floatFromInt(cancel_tex.w), + .h = @floatFromInt(cancel_tex.h), + }); + } + + fn renderSendButton(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, overlay_rect: geom.Rect) void { + if (!self.hasUnsentComments()) return; + + const btn = sendButtonRect(host, overlay_rect); + const alpha = self.render_alpha; + const radius = dpi.scale(4, host.ui_scale); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + const green_alpha: u8 = @intFromFloat(if (self.send_button_hovered) 255.0 * alpha else 200.0 * alpha); + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, green_alpha); + primitives.fillRoundedRect(renderer, btn, radius); + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + const tex = self.makeTextTexture(renderer, fonts.regular, "Send to agent", .{ .r = 255, .g = 255, .b = 255, .a = 255 }) catch return; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(btn.x + @divFloor(btn.w - tex.w, 2)), + .y = @floatFromInt(btn.y + @divFloor(btn.h - tex.h, 2)), + .w = @floatFromInt(tex.w), + .h = @floatFromInt(tex.h), + }); + } + + fn renderAgentDropdown(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, overlay_rect: geom.Rect) void { + if (!self.show_agent_dropdown) return; + + const dd = agentDropdownRect(host, overlay_rect); + const item_h = dpi.scale(agent_dropdown_item_height, host.ui_scale); + const alpha = self.render_alpha; + const radius = dpi.scale(4, host.ui_scale); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + const bg = host.theme.background; + _ = c.SDL_SetRenderDrawColor(renderer, bg.r, bg.g, bg.b, @intFromFloat(250.0 * alpha)); + primitives.fillRoundedRect(renderer, dd, radius); + + const accent = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, @intFromFloat(150.0 * alpha)); + primitives.drawRoundedBorder(renderer, dd, radius); + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + for (dropdown_items, 0..) |name, i| { + const item_y = dd.y + @as(c_int, @intCast(i)) * item_h; + + if (self.agent_dropdown_hovered) |h| { + if (h == i) { + const sel = host.theme.selection; + _ = c.SDL_SetRenderDrawColor(renderer, sel.r, sel.g, sel.b, @intFromFloat(60.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(dd.x + 1), + .y = @floatFromInt(item_y), + .w = @floatFromInt(dd.w - 2), + .h = @floatFromInt(item_h), + }); + } + } + + const tex = self.makeTextTexture(renderer, fonts.regular, name, host.theme.foreground) catch |err| { + log.warn("failed to render dropdown text: {}", .{err}); + continue; + }; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(dd.x + dpi.scale(text_padding, host.ui_scale)), + .y = @floatFromInt(item_y + @divFloor(item_h - tex.h, 2)), + .w = @floatFromInt(tex.w), + .h = @floatFromInt(tex.h), + }); + } + } + fn destroy(self: *DiffOverlayComponent, renderer: *c.SDL_Renderer) void { _ = renderer; self.clearContent(); self.display_rows.deinit(self.allocator); + if (self.arrow_cursor) |cur| c.SDL_DestroyCursor(cur); + if (self.pointer_cursor) |cur| c.SDL_DestroyCursor(cur); + if (self.text_cursor) |cur| c.SDL_DestroyCursor(cur); self.allocator.destroy(self); } diff --git a/src/ui/types.zig b/src/ui/types.zig index 501812a..221452a 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -10,6 +10,7 @@ pub const SessionUiInfo = struct { spawned: bool, cwd_path: ?[]const u8 = null, cwd_basename: ?[]const u8 = null, + session_status: app_state.SessionStatus = .idle, }; pub const UiHost = struct { @@ -54,6 +55,7 @@ pub const UiAction = union(enum) { DespawnSession: usize, ToggleMetrics: void, ToggleDiffOverlay: void, + SendDiffComments: SendDiffCommentsAction, }; pub const SwitchWorktreeAction = struct { @@ -77,6 +79,12 @@ pub const ChangeDirAction = struct { path: []const u8, }; +pub const SendDiffCommentsAction = struct { + session: usize, + comments_text: []const u8, + agent_command: ?[]const u8, +}; + pub const UiAssets = struct { ui_font: ?*font_mod.Font = null, font_cache: ?*font_cache.FontCache = null, From 62081068c46700fd43e98adb3f7c75423c19c96c Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sat, 7 Feb 2026 10:57:32 +0100 Subject: [PATCH 02/10] fix(ui): address PR review comments on diff overlay Fixes rendering performance, correctness, and robustness issues identified in code review for the inline review comments feature. - Compute y_pos incrementally in render loop instead of O(n) per row - Render comment boxes for above-viewport rows before skipping - Extract renderRowComments helper for reuse - Free previous pending_comment_send before overwriting - Escape control chars < 0x20 as \u00XX in JSON persistence - Set text cursor when hovering over editing comment input area - Document heap ownership on SendDiffCommentsAction fields - Don't persist sent state to file so comments survive delivery failure --- src/app/runtime.zig | 1 + src/ui/components/diff_overlay.zig | 78 +++++++++++++++++++----------- src/ui/types.zig | 2 + 3 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index d51bcf7..97bf7e1 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -1868,6 +1868,7 @@ pub fn run() !void { continue; }; allocator.free(cmd); + if (pending_comment_send) |prev| allocator.free(prev.text); pending_comment_send = .{ .session = dc_action.session, .text = dc_action.comments_text, diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index 6ec7093..e8d9fe6 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -1258,7 +1258,17 @@ pub const DiffOverlayComponent = struct { } } }, - .comment_box => want_cursor = .pointer, + .comment_box => |box_row| { + if (self.editing) |ed| { + if (ed.target_display_row == box_row) { + want_cursor = .text; + } else { + want_cursor = .pointer; + } + } else { + want_cursor = .pointer; + } + }, else => {}, } } @@ -1801,11 +1811,8 @@ pub const DiffOverlayComponent = struct { const row_height = cache.line_height; if (row_height <= 0 or content_h <= 0) return; - // With comments, we can't simply divide to find first_visible - // Use conservative estimate: start from 0 if we have comments const has_comments = self.comments.items.len > 0 or self.editing != null; const first_visible: usize = if (has_comments) 0 else @intCast(@divFloor(scroll_int, row_height)); - const visible_count: usize = if (has_comments) self.display_rows.items.len else @intCast(@divFloor(content_h, row_height) + 2); const scaled_gutter_w = dpi.scale(gutter_width, host.ui_scale); const scaled_chevron_sz = dpi.scale(chevron_size, host.ui_scale); @@ -1815,21 +1822,26 @@ pub const DiffOverlayComponent = struct { const fg = host.theme.foreground; const accent = host.theme.accent; - const end_row = @min(self.display_rows.items.len, first_visible + visible_count); + // Compute y_pos incrementally to avoid O(n²) from per-row computeRowY calls + var cumulative_y: c_int = if (has_comments) + content_top + self.computeRowY(first_visible, row_height, host.ui_scale) - scroll_int + else + content_top + @as(c_int, @intCast(first_visible)) * row_height - scroll_int; + var row_index: usize = first_visible; - while (row_index < end_row) : (row_index += 1) { + while (row_index < self.display_rows.items.len) : (row_index += 1) { const row = self.display_rows.items[row_index]; - const y_pos = if (has_comments) - content_top + self.computeRowY(row_index, row_height, host.ui_scale) - scroll_int - else - content_top + @as(c_int, @intCast(row_index)) * row_height - scroll_int; + const y_pos = cumulative_y; - // Skip rows that are above the viewport + // Advance cumulative_y for the next iteration (row height + any comment height) + cumulative_y += row_height + self.commentHeightAtRow(row_index, host.ui_scale); + + // Skip rows above the viewport, but render their attached comments if (y_pos + row_height < content_top) { - // But still need to render any comments attached to this row + self.renderRowComments(host, renderer, assets, rect, y_pos + row_height, row_index); continue; } - // Skip rows that are below the viewport + // Stop when below the viewport if (y_pos > content_top + content_h) break; switch (row.kind) { @@ -1972,21 +1984,24 @@ pub const DiffOverlayComponent = struct { }); } - // Render comments attached to this row - var comment_y = y_pos + row_height; - for (self.comments.items) |comment| { - if (comment.sent) continue; - if (comment.display_row_index) |dri| { - if (dri == row_index) { - self.renderSavedComment(host, renderer, assets, rect, comment_y, comment); - comment_y += dpi.scale(saved_comment_height, host.ui_scale); - } + self.renderRowComments(host, renderer, assets, rect, y_pos + row_height, row_index); + } + } + + fn renderRowComments(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, base_y: c_int, row_index: usize) void { + var comment_y = base_y; + for (self.comments.items) |comment| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_index) { + self.renderSavedComment(host, renderer, assets, rect, comment_y, comment); + comment_y += dpi.scale(saved_comment_height, host.ui_scale); } } - if (self.editing) |_| { - if (self.editing.?.target_display_row == row_index) { - self.renderEditingComment(host, renderer, assets, rect, comment_y); - } + } + if (self.editing) |_| { + if (self.editing.?.target_display_row == row_index) { + self.renderEditingComment(host, renderer, assets, rect, comment_y); } } } @@ -2371,7 +2386,6 @@ pub const DiffOverlayComponent = struct { comment.display_row_index = null; } self.destroyCache(); - self.saveCommentsToFile(); } fn sendButtonRect(host: *const types.UiHost, overlay_rect: geom.Rect) geom.Rect { @@ -2455,7 +2469,15 @@ pub const DiffOverlayComponent = struct { '\n' => buf.appendSlice(self.allocator, "\\n") catch return, '\r' => buf.appendSlice(self.allocator, "\\r") catch return, '\t' => buf.appendSlice(self.allocator, "\\t") catch return, - else => buf.append(self.allocator, ch) catch return, + else => { + if (ch < 0x20) { + var esc_buf: [6]u8 = undefined; + const esc = std.fmt.bufPrint(&esc_buf, "\\u{X:0>4}", .{ch}) catch return; + buf.appendSlice(self.allocator, esc) catch return; + } else { + buf.append(self.allocator, ch) catch return; + } + }, } } } diff --git a/src/ui/types.zig b/src/ui/types.zig index 221452a..47b2504 100644 --- a/src/ui/types.zig +++ b/src/ui/types.zig @@ -81,7 +81,9 @@ pub const ChangeDirAction = struct { pub const SendDiffCommentsAction = struct { session: usize, + /// Heap-allocated; ownership transfers to runtime, which frees after send. comments_text: []const u8, + /// Heap-allocated; ownership transfers to runtime, which frees after send. agent_command: ?[]const u8, }; From 3397d33d81905e9078c0f1864589cfd5d4fa4880 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sat, 7 Feb 2026 15:27:20 +0100 Subject: [PATCH 03/10] feat(ui): add comment animations and delete button to diff overlay Add fluid animations for the comment editor lifecycle: smooth expand on open (200ms easeOutCubic), collapse on cancel (150ms easeInOutCubic), morph transition on submit with height/color interpolation (300ms), and a warm amber glow confirming the save (500ms). Add a delete button (x) on each saved comment for direct removal without opening the editor. --- src/anim/easing.zig | 5 + src/c.zig | 1 + src/ui/components/diff_overlay.zig | 684 ++++++++++++++++++++++++++--- 3 files changed, 639 insertions(+), 51 deletions(-) diff --git a/src/anim/easing.zig b/src/anim/easing.zig index 23fbe8a..779076a 100644 --- a/src/anim/easing.zig +++ b/src/anim/easing.zig @@ -6,3 +6,8 @@ pub fn easeInOutCubic(t: f32) f32 { const p = 2 * t - 2; return 1 + p * p * p / 2; } + +pub fn easeOutCubic(t: f32) f32 { + const p = t - 1.0; + return 1.0 + p * p * p; +} diff --git a/src/c.zig b/src/c.zig index 7ff2ca7..8374c1a 100644 --- a/src/c.zig +++ b/src/c.zig @@ -47,6 +47,7 @@ else 0x0000_0004; pub const SDL_SetRenderDrawBlendMode = c_import.SDL_SetRenderDrawBlendMode; pub const SDL_SetRenderClipRect = c_import.SDL_SetRenderClipRect; +pub const SDL_GetRenderClipRect = c_import.SDL_GetRenderClipRect; pub const SDL_GetTextureSize = c_import.SDL_GetTextureSize; pub const SDL_CreateTextureFromSurface = c_import.SDL_CreateTextureFromSurface; pub const SDL_DestroyTexture = c_import.SDL_DestroyTexture; diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index e8d9fe6..3174920 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -140,6 +140,7 @@ pub const DiffOverlayComponent = struct { show_agent_dropdown: bool = false, agent_dropdown_hovered: ?usize = null, send_button_hovered: bool = false, + delete_hovered_comment: ?usize = null, wrap_cols: usize = 0, @@ -152,11 +153,28 @@ pub const DiffOverlayComponent = struct { animation_start_ms: i64 = 0, render_alpha: f32 = 1.0, + comment_anim: ?CommentAnimKind = null, + comment_anim_start_ms: i64 = 0, + comment_anim_row: usize = 0, + submit_anim_text: ?[]const u8 = null, + const CursorKind = enum { arrow, pointer, text }; const AnimationState = enum { closed, opening, open, closing }; const animation_duration_ms: i64 = 250; const scale_from: f32 = 0.97; + const CommentAnimKind = enum { + editor_opening, + editor_closing, + submitting, + submitted_glow, + }; + + const editor_open_duration_ms: i64 = 200; + const editor_close_duration_ms: i64 = 150; + const submit_morph_duration_ms: i64 = 300; + const submit_glow_duration_ms: i64 = 500; + const margin: c_int = 40; const title_height: c_int = 50; const close_btn_size: c_int = 32; @@ -178,6 +196,7 @@ pub const DiffOverlayComponent = struct { const comment_input_height: c_int = 44; const comment_button_height: c_int = 28; const comment_button_width: c_int = 70; + const comment_delete_btn_size: c_int = 16; const agent_dropdown_item_height: c_int = 28; const agent_dropdown_width: c_int = 140; const send_button_width: c_int = 110; @@ -760,7 +779,7 @@ pub const DiffOverlayComponent = struct { self.files = .{}; self.hovered_file = null; self.freeComments(); - self.cancelEditing(); + self.cancelEditingImmediate(); self.show_agent_dropdown = false; self.agent_dropdown_hovered = null; self.send_button_hovered = false; @@ -899,6 +918,46 @@ pub const DiffOverlayComponent = struct { return easing.easeInOutCubic(t); } + fn commentAnimProgress(self: *const DiffOverlayComponent, now_ms: i64) f32 { + const anim = self.comment_anim orelse return 1.0; + const duration: i64 = switch (anim) { + .editor_opening => editor_open_duration_ms, + .editor_closing => editor_close_duration_ms, + .submitting => submit_morph_duration_ms, + .submitted_glow => submit_glow_duration_ms, + }; + const elapsed = now_ms - self.comment_anim_start_ms; + const clamped = @max(@as(i64, 0), elapsed); + const t = @min(1.0, @as(f32, @floatFromInt(clamped)) / @as(f32, @floatFromInt(duration))); + return switch (anim) { + .editor_opening => easing.easeOutCubic(t), + .editor_closing => easing.easeInOutCubic(t), + .submitting => easing.easeInOutCubic(t), + .submitted_glow => t, + }; + } + + fn finishCommentAnim(self: *DiffOverlayComponent) void { + if (self.comment_anim) |anim| { + if (anim == .editor_closing) { + self.finishCancelEditing(); + } + } + if (self.submit_anim_text) |txt| { + self.allocator.free(txt); + self.submit_anim_text = null; + } + self.comment_anim = null; + } + + fn finishCancelEditing(self: *DiffOverlayComponent) void { + if (self.editing) |*ed| { + ed.input_buf.deinit(self.allocator); + self.allocator.free(ed.key.file_path); + self.editing = null; + } + } + fn animatedOverlayRect(host: *const types.UiHost, progress: f32) geom.Rect { const base = overlayRect(host); const scale = scale_from + (1.0 - scale_from) * progress; @@ -978,12 +1037,14 @@ pub const DiffOverlayComponent = struct { const has_blocking = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_SHIFT)) != 0; // Editing text input: handle special keys - if (self.editing != null) { + const editor_interactive = self.editing != null and + (self.comment_anim == null or self.comment_anim.? != .editor_closing); + if (editor_interactive) { if (key == c.SDLK_ESCAPE) { if (self.show_agent_dropdown) { self.show_agent_dropdown = false; } else { - self.cancelEditing(); + self.cancelEditing(host.now_ms); } return true; } @@ -994,7 +1055,7 @@ pub const DiffOverlayComponent = struct { }; self.editing.?.cursor_blink_start_ms = host.now_ms; } else { - self.submitComment(); + self.submitComment(host.now_ms); } return true; } @@ -1050,11 +1111,14 @@ pub const DiffOverlayComponent = struct { }, c.SDL_EVENT_TEXT_INPUT => { if (self.editing) |*ed| { - const text = std.mem.span(event.text.text); - ed.input_buf.appendSlice(self.allocator, text) catch |err| { - log.warn("failed to append text input: {}", .{err}); - }; - ed.cursor_blink_start_ms = host.now_ms; + const is_closing = if (self.comment_anim) |a| a == .editor_closing else false; + if (!is_closing) { + const text = std.mem.span(event.text.text); + ed.input_buf.appendSlice(self.allocator, text) catch |err| { + log.warn("failed to append text input: {}", .{err}); + }; + ed.cursor_blink_start_ms = host.now_ms; + } } return true; }, @@ -1123,7 +1187,7 @@ pub const DiffOverlayComponent = struct { const scroll_int: c_int = @intFromFloat(self.scroll_offset); const content_top = rect.y + scaled_title_h; - const comment_y_base = self.computeRowY(ed.target_display_row, scaled_line_h, host.ui_scale) + scaled_line_h; + const comment_y_base = self.computeRowY(ed.target_display_row, scaled_line_h, host.ui_scale, host.now_ms) + scaled_line_h; // Subtract any saved comment height at this row (editing comes after saved) var saved_h: c_int = 0; for (self.comments.items) |comment| { @@ -1140,11 +1204,11 @@ pub const DiffOverlayComponent = struct { const cancel_x = submit_x + btn_w + dpi.scale(6, host.ui_scale); if (geom.containsPoint(.{ .x = submit_x, .y = btn_y, .w = btn_w, .h = btn_h }, mouse_x, mouse_y)) { - self.submitComment(); + self.submitComment(host.now_ms); return true; } if (geom.containsPoint(.{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, mouse_x, mouse_y)) { - self.cancelEditing(); + self.cancelEditing(host.now_ms); return true; } } @@ -1158,7 +1222,7 @@ pub const DiffOverlayComponent = struct { if (mouse_y >= content_top and scaled_line_h > 0) { const relative_y = mouse_y - content_top + scroll_int; if (relative_y >= 0) { - const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale); + const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale, host.now_ms); switch (target) { .diff_row => |row_idx| { const row = self.display_rows.items[row_idx]; @@ -1173,13 +1237,25 @@ pub const DiffOverlayComponent = struct { } }, .comment_box => |row_idx| { + // Check if clicking the delete button + if (self.findCommentDeleteTarget(host, row_idx, mouse_x, mouse_y)) |del_idx| { + if (self.editing) |ed| { + if (ed.existing_index != null and ed.existing_index.? == del_idx) { + self.cancelEditingImmediate(); + } + } + self.removeComment(del_idx); + self.destroyCache(); + self.saveCommentsToFile(); + return true; + } // Click on saved comment opens for editing for (self.comments.items, 0..) |comment, ci| { if (comment.sent) continue; if (comment.display_row_index) |dri| { if (dri == row_idx) { // Open this comment for editing - self.cancelEditing(); + self.cancelEditingImmediate(); const key_dup = self.allocator.dupe(u8, comment.key.file_path) catch return true; var input_buf = std.ArrayList(u8){}; input_buf.appendSlice(self.allocator, comment.text) catch |err| { @@ -1194,6 +1270,9 @@ pub const DiffOverlayComponent = struct { .cursor_blink_start_ms = host.now_ms, .existing_index = ci, }; + self.comment_anim = .editor_opening; + self.comment_anim_start_ms = host.now_ms; + self.comment_anim_row = row_idx; self.first_frame.markTransition(); break; } @@ -1237,6 +1316,7 @@ pub const DiffOverlayComponent = struct { const scroll_int: c_int = @intFromFloat(self.scroll_offset); self.hovered_file = null; + self.delete_hovered_comment = null; var want_cursor: CursorKind = .arrow; if (self.close_hovered or self.send_button_hovered) { want_cursor = .pointer; @@ -1245,7 +1325,7 @@ pub const DiffOverlayComponent = struct { } else if (mouse_y >= content_top and scaled_line_h > 0) { const relative_y = mouse_y - content_top + scroll_int; if (relative_y >= 0) { - const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale); + const target = self.resolveClickTarget(relative_y, scaled_line_h, host.ui_scale, host.now_ms); switch (target) { .diff_row => |row_idx| { if (row_idx < self.display_rows.items.len) { @@ -1259,7 +1339,10 @@ pub const DiffOverlayComponent = struct { } }, .comment_box => |box_row| { - if (self.editing) |ed| { + self.delete_hovered_comment = self.findCommentDeleteTarget(host, box_row, mouse_x, mouse_y); + if (self.delete_hovered_comment != null) { + want_cursor = .pointer; + } else if (self.editing) |ed| { if (ed.target_display_row == box_row) { want_cursor = .text; } else { @@ -1300,6 +1383,38 @@ pub const DiffOverlayComponent = struct { }, .open, .closed => {}, } + + if (self.comment_anim) |anim| { + const comment_elapsed = host.now_ms - self.comment_anim_start_ms; + const duration: i64 = switch (anim) { + .editor_opening => editor_open_duration_ms, + .editor_closing => editor_close_duration_ms, + .submitting => submit_morph_duration_ms, + .submitted_glow => submit_glow_duration_ms, + }; + if (comment_elapsed >= duration) { + switch (anim) { + .editor_opening => { + self.comment_anim = null; + }, + .editor_closing => { + self.finishCancelEditing(); + self.comment_anim = null; + }, + .submitting => { + self.comment_anim = .submitted_glow; + self.comment_anim_start_ms = host.now_ms; + }, + .submitted_glow => { + if (self.submit_anim_text) |txt| { + self.allocator.free(txt); + self.submit_anim_text = null; + } + self.comment_anim = null; + }, + } + } + } } fn hitTestFn(self_ptr: *anyopaque, host: *const types.UiHost, x: c_int, y: c_int) bool { @@ -1311,7 +1426,7 @@ pub const DiffOverlayComponent = struct { fn wantsFrameFn(self_ptr: *anyopaque, _: *const types.UiHost) bool { const self: *DiffOverlayComponent = @ptrCast(@alignCast(self_ptr)); - return self.first_frame.wantsFrame() or self.visible or self.animation_state == .closing; + return self.first_frame.wantsFrame() or self.visible or self.animation_state == .closing or self.comment_anim != null; } // --- Rendering --- @@ -1824,7 +1939,7 @@ pub const DiffOverlayComponent = struct { // Compute y_pos incrementally to avoid O(n²) from per-row computeRowY calls var cumulative_y: c_int = if (has_comments) - content_top + self.computeRowY(first_visible, row_height, host.ui_scale) - scroll_int + content_top + self.computeRowY(first_visible, row_height, host.ui_scale, host.now_ms) - scroll_int else content_top + @as(c_int, @intCast(first_visible)) * row_height - scroll_int; @@ -1834,7 +1949,7 @@ pub const DiffOverlayComponent = struct { const y_pos = cumulative_y; // Advance cumulative_y for the next iteration (row height + any comment height) - cumulative_y += row_height + self.commentHeightAtRow(row_index, host.ui_scale); + cumulative_y += row_height + self.commentHeightAtRow(row_index, host.ui_scale, host.now_ms); // Skip rows above the viewport, but render their attached comments if (y_pos + row_height < content_top) { @@ -1990,18 +2105,43 @@ pub const DiffOverlayComponent = struct { fn renderRowComments(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, base_y: c_int, row_index: usize) void { var comment_y = base_y; - for (self.comments.items) |comment| { + const is_anim_row = self.comment_anim != null and self.comment_anim_row == row_index; + const anim_p = if (is_anim_row) self.commentAnimProgress(host.now_ms) else @as(f32, 1.0); + + for (self.comments.items, 0..) |comment, ci| { if (comment.sent) continue; if (comment.display_row_index) |dri| { if (dri == row_index) { - self.renderSavedComment(host, renderer, assets, rect, comment_y, comment); - comment_y += dpi.scale(saved_comment_height, host.ui_scale); + if (is_anim_row and self.comment_anim.? == .submitting) { + self.renderSubmitMorph(host, renderer, assets, rect, comment_y, comment, anim_p); + const full_edit_h: f32 = @floatFromInt(dpi.scale(editing_comment_height, host.ui_scale)); + const full_saved_h: f32 = @floatFromInt(dpi.scale(saved_comment_height, host.ui_scale)); + comment_y += @intFromFloat(full_edit_h + (full_saved_h - full_edit_h) * anim_p); + } else if (is_anim_row and self.comment_anim.? == .submitted_glow) { + self.renderSavedCommentWithGlow(host, renderer, assets, rect, comment_y, comment, anim_p, ci); + comment_y += dpi.scale(saved_comment_height, host.ui_scale); + } else { + self.renderSavedComment(host, renderer, assets, rect, comment_y, comment, ci); + comment_y += dpi.scale(saved_comment_height, host.ui_scale); + } } } } - if (self.editing) |_| { - if (self.editing.?.target_display_row == row_index) { - self.renderEditingComment(host, renderer, assets, rect, comment_y); + if (self.editing) |ed| { + if (ed.target_display_row == row_index) { + if (is_anim_row) { + if (self.comment_anim) |anim| { + if (anim == .editor_opening or anim == .editor_closing) { + self.renderEditingCommentAnimated(host, renderer, assets, rect, comment_y, anim_p, anim == .editor_closing); + } else { + self.renderEditingComment(host, renderer, assets, rect, comment_y); + } + } else { + self.renderEditingComment(host, renderer, assets, rect, comment_y); + } + } else { + self.renderEditingComment(host, renderer, assets, rect, comment_y); + } } } } @@ -2075,14 +2215,29 @@ pub const DiffOverlayComponent = struct { self.comments = .{}; } - fn cancelEditing(self: *DiffOverlayComponent) void { - if (self.editing) |*ed| { - ed.input_buf.deinit(self.allocator); - self.allocator.free(ed.key.file_path); - self.editing = null; + fn cancelEditing(self: *DiffOverlayComponent, now_ms: i64) void { + if (self.editing) |ed| { + if (self.comment_anim) |anim| { + if (anim == .editor_closing) return; + if (anim == .editor_opening) { + // Interrupt opening with close + self.comment_anim = .editor_closing; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = ed.target_display_row; + return; + } + } + self.comment_anim = .editor_closing; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = ed.target_display_row; } } + fn cancelEditingImmediate(self: *DiffOverlayComponent) void { + self.finishCommentAnim(); + self.finishCancelEditing(); + } + fn commentKeyForRow(self: *DiffOverlayComponent, row_index: usize) ?CommentKey { if (row_index >= self.display_rows.items.len) return null; const row = self.display_rows.items[row_index]; @@ -2149,7 +2304,7 @@ pub const DiffOverlayComponent = struct { self.allocator.free(key.file_path); return; } - self.cancelEditing(); + self.cancelEditingImmediate(); } const existing_idx = self.findCommentIndex(key); @@ -2167,27 +2322,39 @@ pub const DiffOverlayComponent = struct { .cursor_blink_start_ms = now_ms, .existing_index = existing_idx, }; + self.comment_anim = .editor_opening; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = attach_row; self.first_frame.markTransition(); } - fn submitComment(self: *DiffOverlayComponent) void { + fn submitComment(self: *DiffOverlayComponent, now_ms: i64) void { const ed = &(self.editing orelse return); if (ed.input_buf.items.len == 0) { if (ed.existing_index) |idx| { self.removeComment(idx); } - self.cancelEditing(); + self.cancelEditing(now_ms); self.saveCommentsToFile(); return; } + const anim_row = ed.target_display_row; + + const anim_text = self.allocator.dupe(u8, ed.input_buf.items) catch |err| { + log.warn("failed to dupe anim text: {}", .{err}); + return; + }; + const text = self.allocator.dupe(u8, ed.input_buf.items) catch |err| { log.warn("failed to dupe comment text: {}", .{err}); + self.allocator.free(anim_text); return; }; const file_path = self.allocator.dupe(u8, ed.key.file_path) catch |err| { log.warn("failed to dupe comment path: {}", .{err}); self.allocator.free(text); + self.allocator.free(anim_text); return; }; @@ -2200,16 +2367,22 @@ pub const DiffOverlayComponent = struct { .key = .{ .file_path = file_path, .line_number = ed.key.line_number }, .text = text, .sent = false, - .display_row_index = ed.target_display_row, + .display_row_index = anim_row, }) catch |err| { log.warn("failed to append comment: {}", .{err}); self.allocator.free(text); self.allocator.free(file_path); + self.allocator.free(anim_text); return; }; } - self.cancelEditing(); + self.finishCancelEditing(); + if (self.submit_anim_text) |old| self.allocator.free(old); + self.submit_anim_text = anim_text; + self.comment_anim = .submitting; + self.comment_anim_start_ms = now_ms; + self.comment_anim_row = anim_row; self.destroyCache(); self.saveCommentsToFile(); } @@ -2271,19 +2444,43 @@ pub const DiffOverlayComponent = struct { } } - fn commentHeightAtRow(self: *DiffOverlayComponent, row_index: usize, ui_scale: f32) c_int { + fn commentHeightAtRow(self: *DiffOverlayComponent, row_index: usize, ui_scale: f32, now_ms: i64) c_int { var h: c_int = 0; + const full_saved_h = dpi.scale(saved_comment_height, ui_scale); + const full_edit_h = dpi.scale(editing_comment_height, ui_scale); + const is_anim_row = self.comment_anim != null and self.comment_anim_row == row_index; + for (self.comments.items) |comment| { if (comment.sent) continue; if (comment.display_row_index) |dri| { if (dri == row_index) { - h += dpi.scale(saved_comment_height, ui_scale); + if (is_anim_row and self.comment_anim.? == .submitting) { + // During morph: height interpolates from editing to saved + const p = self.commentAnimProgress(now_ms); + const edit_f: f32 = @floatFromInt(full_edit_h); + const saved_f: f32 = @floatFromInt(full_saved_h); + h += @intFromFloat(edit_f + (saved_f - edit_f) * p); + } else { + h += full_saved_h; + } } } } if (self.editing) |ed| { if (ed.target_display_row == row_index) { - h += dpi.scale(editing_comment_height, ui_scale); + if (is_anim_row) { + const p = self.commentAnimProgress(now_ms); + const edit_f: f32 = @floatFromInt(full_edit_h); + if (self.comment_anim.? == .editor_opening) { + h += @intFromFloat(edit_f * p); + } else if (self.comment_anim.? == .editor_closing) { + h += @intFromFloat(edit_f * (1.0 - p)); + } else { + h += full_edit_h; + } + } else { + h += full_edit_h; + } } } return h; @@ -2291,27 +2488,22 @@ pub const DiffOverlayComponent = struct { fn totalCommentPixelHeight(self: *DiffOverlayComponent, host: *const types.UiHost) c_int { var total: c_int = 0; - for (self.comments.items) |comment| { - if (!comment.sent and comment.display_row_index != null) { - total += dpi.scale(saved_comment_height, host.ui_scale); - } - } - if (self.editing) |_| { - total += dpi.scale(editing_comment_height, host.ui_scale); + for (self.display_rows.items, 0..) |_, i| { + total += self.commentHeightAtRow(i, host.ui_scale, host.now_ms); } return total; } - fn computeRowY(self: *DiffOverlayComponent, row_index: usize, row_height: c_int, ui_scale: f32) c_int { + fn computeRowY(self: *DiffOverlayComponent, row_index: usize, row_height: c_int, ui_scale: f32, now_ms: i64) c_int { var y: c_int = @as(c_int, @intCast(row_index)) * row_height; var i: usize = 0; while (i < row_index) : (i += 1) { - y += self.commentHeightAtRow(i, ui_scale); + y += self.commentHeightAtRow(i, ui_scale, now_ms); } return y; } - fn resolveClickTarget(self: *DiffOverlayComponent, relative_y: c_int, row_height: c_int, ui_scale: f32) ClickTarget { + fn resolveClickTarget(self: *DiffOverlayComponent, relative_y: c_int, row_height: c_int, ui_scale: f32, now_ms: i64) ClickTarget { if (row_height <= 0) return .{ .other = {} }; var cumulative_y: c_int = 0; for (self.display_rows.items, 0..) |_, i| { @@ -2321,7 +2513,7 @@ pub const DiffOverlayComponent = struct { return .{ .diff_row = i }; } cumulative_y = row_end; - const comment_h = self.commentHeightAtRow(i, ui_scale); + const comment_h = self.commentHeightAtRow(i, ui_scale, now_ms); if (comment_h > 0 and relative_y >= cumulative_y and relative_y < cumulative_y + comment_h) { return .{ .comment_box = i }; } @@ -2388,6 +2580,43 @@ pub const DiffOverlayComponent = struct { self.destroyCache(); } + fn commentDeleteBtnRect(host: *const types.UiHost, overlay_rect: geom.Rect, comment_y: c_int) geom.Rect { + const btn_size = dpi.scale(comment_delete_btn_size, host.ui_scale); + const comment_h = dpi.scale(saved_comment_height, host.ui_scale); + const margin_r = dpi.scale(8, host.ui_scale); + return .{ + .x = overlay_rect.x + overlay_rect.w - btn_size - margin_r, + .y = comment_y + @divFloor(comment_h - btn_size, 2), + .w = btn_size, + .h = btn_size, + }; + } + + fn findCommentDeleteTarget(self: *DiffOverlayComponent, host: *const types.UiHost, row_idx: usize, mouse_x: c_int, mouse_y: c_int) ?usize { + const rect = overlayRect(host); + const scaled_title_h = dpi.scale(title_height, host.ui_scale); + const scaled_line_h = self.lineHeight(host); + const content_top = rect.y + scaled_title_h; + const scroll_int: c_int = @intFromFloat(self.scroll_offset); + const row_y = content_top + self.computeRowY(row_idx, scaled_line_h, host.ui_scale, host.now_ms) - scroll_int; + var comment_y = row_y + scaled_line_h; + const saved_h = dpi.scale(saved_comment_height, host.ui_scale); + + for (self.comments.items, 0..) |comment, ci| { + if (comment.sent) continue; + if (comment.display_row_index) |dri| { + if (dri == row_idx) { + const del_btn = commentDeleteBtnRect(host, rect, comment_y); + if (geom.containsPoint(del_btn, mouse_x, mouse_y)) { + return ci; + } + comment_y += saved_h; + } + } + } + return null; + } + fn sendButtonRect(host: *const types.UiHost, overlay_rect: geom.Rect) geom.Rect { const btn_w = dpi.scale(send_button_width, host.ui_scale); const btn_h = dpi.scale(send_button_height, host.ui_scale); @@ -2598,11 +2827,12 @@ pub const DiffOverlayComponent = struct { // --- Comment rendering --- - fn renderSavedComment(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment) void { + fn renderSavedComment(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment, comment_idx: usize) void { const comment_h = dpi.scale(saved_comment_height, host.ui_scale); const scaled_padding = dpi.scale(text_padding, host.ui_scale); const accent_w = dpi.scale(4, host.ui_scale); const alpha = self.render_alpha; + const del_btn = commentDeleteBtnRect(host, rect, y_pos); _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); @@ -2636,7 +2866,8 @@ pub const DiffOverlayComponent = struct { _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); const text_x = rect.x + scaled_padding + accent_w + dpi.scale(4, host.ui_scale); const text_y = y_pos + @divFloor(comment_h - tex.h, 2); - const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale); + const del_space = dpi.scale(comment_delete_btn_size + 16, host.ui_scale); + const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale) - del_space; const render_w = @min(tex.w, max_w); _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ .x = @floatFromInt(text_x), @@ -2644,6 +2875,9 @@ pub const DiffOverlayComponent = struct { .w = @floatFromInt(render_w), .h = @floatFromInt(tex.h), }); + + // Delete button "x" + self.renderCommentDeleteBtn(host, renderer, del_btn, comment_idx); } fn renderEditingComment(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int) void { @@ -2777,6 +3011,354 @@ pub const DiffOverlayComponent = struct { }); } + fn renderEditingCommentAnimated(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, progress: f32, is_closing: bool) void { + const ed = self.editing orelse return; + const full_h = dpi.scale(editing_comment_height, host.ui_scale); + const anim_alpha = if (is_closing) 1.0 - progress else progress; + const anim_h_f: f32 = @as(f32, @floatFromInt(full_h)) * anim_alpha; + const anim_h: c_int = @intFromFloat(anim_h_f); + if (anim_h <= 0) return; + + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const input_h = dpi.scale(comment_input_height, host.ui_scale); + const alpha = self.render_alpha * anim_alpha; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background + _ = c.SDL_SetRenderDrawColor(renderer, 40, 44, 52, @intFromFloat(230.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(anim_h), + }); + + // Accent bar — grows with the height + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(200.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(dpi.scale(3, host.ui_scale)), + .h = @floatFromInt(anim_h), + }); + + // Clip interior rendering to the animated height, saving outer clip + var prev_clip: c.SDL_Rect = undefined; + _ = c.SDL_GetRenderClipRect(renderer, &prev_clip); + const anim_clip = c.SDL_Rect{ + .x = rect.x, + .y = y_pos, + .w = rect.w, + .h = anim_h, + }; + _ = c.SDL_SetRenderClipRect(renderer, &anim_clip); + + // Input area + const input_x = rect.x + scaled_padding + dpi.scale(6, host.ui_scale); + const input_y = y_pos + dpi.scale(4, host.ui_scale); + const input_w = rect.w - scaled_padding * 2 - dpi.scale(12, host.ui_scale); + + _ = c.SDL_SetRenderDrawColor(renderer, 30, 33, 40, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + const accent = host.theme.accent; + _ = c.SDL_SetRenderDrawColor(renderer, accent.r, accent.g, accent.b, @intFromFloat(100.0 * alpha)); + _ = c.SDL_RenderRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(input_x), + .y = @floatFromInt(input_y), + .w = @floatFromInt(input_w), + .h = @floatFromInt(input_h), + }); + + // Input text + const font_cache = assets.font_cache orelse { + _ = c.SDL_SetRenderClipRect(renderer, &prev_clip); + return; + }; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch { + _ = c.SDL_SetRenderClipRect(renderer, &prev_clip); + return; + }; + + if (ed.input_buf.items.len > 0) { + const display_text = if (ed.input_buf.items.len > 500) ed.input_buf.items[0..500] else ed.input_buf.items; + if (self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground)) |tex| { + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_y = input_y + dpi.scale(4, host.ui_scale); + const max_text_w = input_w - dpi.scale(8, host.ui_scale); + const render_w = @min(tex.w, max_text_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(input_x + dpi.scale(4, host.ui_scale)), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } else |_| {} + } + + // Buttons + const btn_h = dpi.scale(comment_button_height, host.ui_scale); + const btn_w = dpi.scale(comment_button_width, host.ui_scale); + const btn_y = y_pos + full_h - btn_h - dpi.scale(6, host.ui_scale); + const submit_x = rect.x + rect.w - scaled_padding - btn_w * 2 - dpi.scale(12, host.ui_scale); + _ = c.SDL_SetRenderDrawColor(renderer, 40, 167, 69, @intFromFloat(220.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = submit_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + if (self.makeTextTexture(renderer, fonts.regular, "Submit", .{ .r = 255, .g = 255, .b = 255, .a = 255 })) |submit_tex| { + defer c.SDL_DestroyTexture(submit_tex.tex); + _ = c.SDL_SetTextureAlphaMod(submit_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, submit_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(submit_x + @divFloor(btn_w - submit_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - submit_tex.h, 2)), + .w = @floatFromInt(submit_tex.w), + .h = @floatFromInt(submit_tex.h), + }); + } else |_| {} + + const cancel_x = submit_x + btn_w + dpi.scale(6, host.ui_scale); + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(40.0 * alpha)); + primitives.fillRoundedRect(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(80.0 * alpha)); + primitives.drawRoundedBorder(renderer, .{ .x = cancel_x, .y = btn_y, .w = btn_w, .h = btn_h }, dpi.scale(4, host.ui_scale)); + if (self.makeTextTexture(renderer, fonts.regular, "Cancel", host.theme.foreground)) |cancel_tex| { + defer c.SDL_DestroyTexture(cancel_tex.tex); + _ = c.SDL_SetTextureAlphaMod(cancel_tex.tex, @intFromFloat(255.0 * alpha)); + _ = c.SDL_RenderTexture(renderer, cancel_tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(cancel_x + @divFloor(btn_w - cancel_tex.w, 2)), + .y = @floatFromInt(btn_y + @divFloor(btn_h - cancel_tex.h, 2)), + .w = @floatFromInt(cancel_tex.w), + .h = @floatFromInt(cancel_tex.h), + }); + } else |_| {} + + _ = c.SDL_SetRenderClipRect(renderer, &prev_clip); + } + + fn renderSubmitMorph(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment, progress: f32) void { + const full_edit_h = dpi.scale(editing_comment_height, host.ui_scale); + const full_saved_h = dpi.scale(saved_comment_height, host.ui_scale); + const edit_h_f: f32 = @floatFromInt(full_edit_h); + const saved_h_f: f32 = @floatFromInt(full_saved_h); + const morph_h: c_int = @intFromFloat(edit_h_f + (saved_h_f - edit_h_f) * progress); + if (morph_h <= 0) return; + + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const accent_w = dpi.scale(4, host.ui_scale); + const alpha = self.render_alpha; + + // Interpolate background: editing rgb(40,44,52) → saved rgb(180,140,40) + const bg_r: u8 = @intFromFloat(40.0 + (180.0 - 40.0) * progress); + const bg_g: u8 = @intFromFloat(44.0 + (140.0 - 44.0) * progress); + const bg_b: u8 = @intFromFloat(52.0 + (40.0 - 52.0) * progress); + // Interpolate background alpha: 230 → 50 + const bg_a: u8 = @intFromFloat((230.0 + (50.0 - 230.0) * progress) * alpha); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, bg_r, bg_g, bg_b, bg_a); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(morph_h), + }); + + // Interpolate accent bar: green (40,167,69) → amber (220,170,50) + const ac_r: u8 = @intFromFloat(40.0 + (220.0 - 40.0) * progress); + const ac_g: u8 = @intFromFloat(167.0 + (170.0 - 167.0) * progress); + const ac_b: u8 = @intFromFloat(69.0 + (50.0 - 69.0) * progress); + const ac_a: u8 = @intFromFloat((200.0 + (220.0 - 200.0) * progress) * alpha); + _ = c.SDL_SetRenderDrawColor(renderer, ac_r, ac_g, ac_b, ac_a); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(accent_w), + .h = @floatFromInt(morph_h), + }); + + // Clip to morph area, saving outer clip + var morph_prev_clip: c.SDL_Rect = undefined; + _ = c.SDL_GetRenderClipRect(renderer, &morph_prev_clip); + const morph_clip = c.SDL_Rect{ + .x = rect.x, + .y = y_pos, + .w = rect.w, + .h = morph_h, + }; + _ = c.SDL_SetRenderClipRect(renderer, &morph_clip); + + const font_cache = assets.font_cache orelse { + _ = c.SDL_SetRenderClipRect(renderer, &morph_prev_clip); + return; + }; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch { + _ = c.SDL_SetRenderClipRect(renderer, &morph_prev_clip); + return; + }; + + // Crossfade text: fade out editing text (from submit_anim_text), fade in saved comment text + const text_x = rect.x + scaled_padding + accent_w + dpi.scale(4, host.ui_scale); + const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale); + + // Fading out: input text (first half fades faster) + const fade_out = @max(0.0, 1.0 - progress * 2.0); + if (fade_out > 0.01) { + if (self.submit_anim_text) |anim_text| { + const display_text = if (anim_text.len > 500) anim_text[0..500] else anim_text; + if (self.makeTextTexture(renderer, fonts.regular, display_text, host.theme.foreground)) |tex| { + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha * fade_out)); + const text_y = y_pos + @divFloor(morph_h - tex.h, 2); + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } else |_| {} + } + } + + // Fading in: saved comment text (second half fades in) + const fade_in = @max(0.0, progress * 2.0 - 1.0); + if (fade_in > 0.01) { + const comment_color = c.SDL_Color{ .r = 230, .g = 200, .b = 110, .a = 255 }; + const display_text = if (comment.text.len > 200) comment.text[0..200] else comment.text; + if (self.makeTextTexture(renderer, fonts.regular, display_text, comment_color)) |tex| { + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha * fade_in)); + const text_y = y_pos + @divFloor(morph_h - tex.h, 2); + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + } else |_| {} + } + + _ = c.SDL_SetRenderClipRect(renderer, &morph_prev_clip); + } + + fn renderSavedCommentWithGlow(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, rect: geom.Rect, y_pos: c_int, comment: DiffComment, glow_progress: f32, comment_idx: usize) void { + const comment_h = dpi.scale(saved_comment_height, host.ui_scale); + const scaled_padding = dpi.scale(text_padding, host.ui_scale); + const accent_w = dpi.scale(4, host.ui_scale); + const alpha = self.render_alpha; + const del_btn = commentDeleteBtnRect(host, rect, y_pos); + + // Glow effect: pulse peaks at the start and fades out + const glow = (1.0 - glow_progress) * (1.0 - glow_progress); + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Background with glow: amber tint alpha pulses from ~120 down to 50 + const bg_base_alpha: f32 = 50.0; + const bg_glow_alpha: f32 = bg_base_alpha + 80.0 * glow; + _ = c.SDL_SetRenderDrawColor(renderer, 180, 140, 40, @intFromFloat(bg_glow_alpha * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(rect.w - 2), + .h = @floatFromInt(comment_h), + }); + + // Accent bar with glow: brighter amber + const accent_base_alpha: f32 = 220.0; + const accent_glow_alpha: f32 = @min(255.0, accent_base_alpha + 35.0 * glow); + _ = c.SDL_SetRenderDrawColor(renderer, 220, 170, 50, @intFromFloat(accent_glow_alpha * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x + 1), + .y = @floatFromInt(y_pos), + .w = @floatFromInt(accent_w), + .h = @floatFromInt(comment_h), + }); + + // Outer glow: subtle warm highlight that fades + if (glow > 0.05) { + const outer_glow_alpha: u8 = @intFromFloat(30.0 * glow * alpha); + _ = c.SDL_SetRenderDrawColor(renderer, 220, 180, 60, outer_glow_alpha); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(rect.x), + .y = @floatFromInt(y_pos - 1), + .w = @floatFromInt(rect.w), + .h = @floatFromInt(comment_h + 2), + }); + } + + const font_cache = assets.font_cache orelse return; + const scaled_font_size = dpi.scale(font_size, host.ui_scale); + const fonts = font_cache.get(scaled_font_size) catch return; + + const comment_color = c.SDL_Color{ .r = 230, .g = 200, .b = 110, .a = 255 }; + const display_text = if (comment.text.len > 200) comment.text[0..200] else comment.text; + const tex = self.makeTextTexture(renderer, fonts.regular, display_text, comment_color) catch return; + defer c.SDL_DestroyTexture(tex.tex); + _ = c.SDL_SetTextureAlphaMod(tex.tex, @intFromFloat(255.0 * alpha)); + const text_x = rect.x + scaled_padding + accent_w + dpi.scale(4, host.ui_scale); + const text_y = y_pos + @divFloor(comment_h - tex.h, 2); + const del_space = dpi.scale(comment_delete_btn_size + 16, host.ui_scale); + const max_w = rect.w - scaled_padding * 2 - accent_w - dpi.scale(8, host.ui_scale) - del_space; + const render_w = @min(tex.w, max_w); + _ = c.SDL_RenderTexture(renderer, tex.tex, null, &c.SDL_FRect{ + .x = @floatFromInt(text_x), + .y = @floatFromInt(text_y), + .w = @floatFromInt(render_w), + .h = @floatFromInt(tex.h), + }); + + // Delete button "x" + self.renderCommentDeleteBtn(host, renderer, del_btn, comment_idx); + } + + fn renderCommentDeleteBtn(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, btn: geom.Rect, comment_idx: usize) void { + const alpha = self.render_alpha; + const is_hovered = if (self.delete_hovered_comment) |hc| hc == comment_idx else false; + const btn_alpha: f32 = if (is_hovered) 220.0 else 100.0; + + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + + // Hover background + if (is_hovered) { + _ = c.SDL_SetRenderDrawColor(renderer, 180, 140, 40, @intFromFloat(60.0 * alpha)); + _ = c.SDL_RenderFillRect(renderer, &c.SDL_FRect{ + .x = @floatFromInt(btn.x), + .y = @floatFromInt(btn.y), + .w = @floatFromInt(btn.w), + .h = @floatFromInt(btn.h), + }); + } + + // Draw "x" cross + const fg = host.theme.foreground; + _ = c.SDL_SetRenderDrawColor(renderer, fg.r, fg.g, fg.b, @intFromFloat(btn_alpha * alpha)); + + const inset = @divFloor(btn.w, 4); + const x1: f32 = @floatFromInt(btn.x + inset); + const y1: f32 = @floatFromInt(btn.y + inset); + const x2: f32 = @floatFromInt(btn.x + btn.w - inset); + const y2: f32 = @floatFromInt(btn.y + btn.h - inset); + + _ = c.SDL_RenderLine(renderer, x1, y1, x2, y2); + _ = c.SDL_RenderLine(renderer, x2, y1, x1, y2); + + // Thicker lines on hover + if (is_hovered) { + _ = c.SDL_RenderLine(renderer, x1 + 1.0, y1, x2 + 1.0, y2); + _ = c.SDL_RenderLine(renderer, x2 + 1.0, y1, x1 + 1.0, y2); + } + } + fn renderSendButton(self: *DiffOverlayComponent, host: *const types.UiHost, renderer: *c.SDL_Renderer, assets: *types.UiAssets, overlay_rect: geom.Rect) void { if (!self.hasUnsentComments()) return; From 0eae9150529bb86383dae3445745de50b503ae24 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sun, 8 Feb 2026 07:48:31 +0100 Subject: [PATCH 04/10] feat(ui): detect agent prompt before sending diff comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of always waiting the full 2-second delay before pasting diff comments into an agent session, scan the terminal screen for known agent prompt characters (> for Gemini, ❯ for Claude, › for Codex). Comments are sent as soon as a prompt is detected, with the timeout kept as a fallback. --- src/app/runtime.zig | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 97bf7e1..486263f 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -98,6 +98,33 @@ fn highestSpawnedIndex(sessions: []const *SessionState) ?usize { return null; } +fn terminalHasAgentPrompt(session: *const SessionState) bool { + const terminal = session.terminal orelse return false; + const pages = terminal.screens.active.pages; + const rows = session.pty_size.ws_row; + const cols = session.pty_size.ws_col; + var row: usize = rows; + while (row > 0) { + row -= 1; + var col: usize = 0; + while (col < cols) : (col += 1) { + const list_cell = pages.getCell(.{ .active = .{ + .x = @intCast(col), + .y = @intCast(row), + } }) orelse continue; + const cp: u21 = if (list_cell.cell.content_tag == .codepoint) + list_cell.cell.content.codepoint + else + 0; + if (cp == ' ' or cp == 0) continue; + // First non-whitespace codepoint on this row + if (cp == '>' or cp == 0x276F or cp == 0x203A) return true; + break; // non-matching, non-whitespace — try next row + } + } + return false; +} + fn adjustedRenderHeightForMode(mode: app_state.ViewMode, render_height: c_int, ui_scale: f32, grid_rows: usize) c_int { return switch (mode) { .Grid, .Expanding, .Collapsing, .GridResizing => blk: { @@ -1419,7 +1446,9 @@ pub fn run() !void { } if (pending_comment_send) |pcs| { - if (now >= pcs.send_after_ms) { + const prompt_ready = pcs.session < sessions.len and + terminalHasAgentPrompt(sessions[pcs.session]); + if (now >= pcs.send_after_ms or prompt_ready) { if (pcs.session < sessions.len) { sessions[pcs.session].sendInput(pcs.text) catch |err| { log.warn("failed to send pending diff comments: {}", .{err}); From c9d14b74b737b45ac7c4c244750d5e62caddf9e2 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sun, 8 Feb 2026 08:25:20 +0100 Subject: [PATCH 05/10] fix(ui): consume all input events during diff overlay close animation Previously, key repeat events (e.g. Escape) during the close animation leaked through to the terminal because the closing state only intercepted Cmd+D. Now all input events are consumed while the overlay animates out. --- src/ui/components/diff_overlay.zig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index 3174920..0147a92 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -1011,7 +1011,7 @@ pub const DiffOverlayComponent = struct { fn handleEventFn(self_ptr: *anyopaque, host: *const types.UiHost, event: *const c.SDL_Event, actions: *types.UiActionQueue) bool { const self: *DiffOverlayComponent = @ptrCast(@alignCast(self_ptr)); - if (!self.visible or self.animation_state == .closing) { + if (!self.visible) { if (event.type == c.SDL_EVENT_KEY_DOWN) { const key = event.key.key; const mod = event.key.mod; @@ -1028,6 +1028,15 @@ pub const DiffOverlayComponent = struct { return false; } + // During close animation, consume all input events to prevent + // key repeats (e.g. Escape) from leaking to the terminal. + if (self.animation_state == .closing) { + return switch (event.type) { + c.SDL_EVENT_KEY_DOWN, c.SDL_EVENT_KEY_UP, c.SDL_EVENT_TEXT_INPUT, c.SDL_EVENT_TEXT_EDITING, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_MOUSE_WHEEL, c.SDL_EVENT_MOUSE_MOTION => true, + else => false, + }; + } + switch (event.type) { c.SDL_EVENT_KEY_DOWN => { const key = event.key.key; From 9e4879626e0f6c1dd04f2d2a56f19586e8660b10 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sun, 8 Feb 2026 09:16:06 +0100 Subject: [PATCH 06/10] fix(ui): check only cursor row for agent prompt detection Scanning all visible rows caused false positives when previous agent output was still on screen. Now only the cursor's current row is checked, since the agent prompt always appears at the cursor position. Also fixes lint warnings by replacing forced optional unwraps with safe if-unwrap patterns in the comment editor. --- src/app/runtime.zig | 32 ++++++++++++------------------ src/ui/components/diff_overlay.zig | 10 ++++++---- 2 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 486263f..4b7e359 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -101,26 +101,20 @@ fn highestSpawnedIndex(sessions: []const *SessionState) ?usize { fn terminalHasAgentPrompt(session: *const SessionState) bool { const terminal = session.terminal orelse return false; const pages = terminal.screens.active.pages; - const rows = session.pty_size.ws_row; const cols = session.pty_size.ws_col; - var row: usize = rows; - while (row > 0) { - row -= 1; - var col: usize = 0; - while (col < cols) : (col += 1) { - const list_cell = pages.getCell(.{ .active = .{ - .x = @intCast(col), - .y = @intCast(row), - } }) orelse continue; - const cp: u21 = if (list_cell.cell.content_tag == .codepoint) - list_cell.cell.content.codepoint - else - 0; - if (cp == ' ' or cp == 0) continue; - // First non-whitespace codepoint on this row - if (cp == '>' or cp == 0x276F or cp == 0x203A) return true; - break; // non-matching, non-whitespace — try next row - } + const row = terminal.screens.active.cursor.y; + var col: usize = 0; + while (col < cols) : (col += 1) { + const list_cell = pages.getCell(.{ .active = .{ + .x = @intCast(col), + .y = @intCast(row), + } }) orelse continue; + const cp: u21 = if (list_cell.cell.content_tag == .codepoint) + list_cell.cell.content.codepoint + else + 0; + if (cp == ' ' or cp == 0) continue; + return cp == '>' or cp == 0x276F or cp == 0x203A; } return false; } diff --git a/src/ui/components/diff_overlay.zig b/src/ui/components/diff_overlay.zig index 0147a92..b2b8cdb 100644 --- a/src/ui/components/diff_overlay.zig +++ b/src/ui/components/diff_overlay.zig @@ -1059,10 +1059,12 @@ pub const DiffOverlayComponent = struct { } if (key == c.SDLK_RETURN or key == c.SDLK_RETURN2 or key == c.SDLK_KP_ENTER) { if (has_shift) { - self.editing.?.input_buf.append(self.allocator, '\n') catch |err| { - log.warn("failed to append newline: {}", .{err}); - }; - self.editing.?.cursor_blink_start_ms = host.now_ms; + if (self.editing) |*ed| { + ed.input_buf.append(self.allocator, '\n') catch |err| { + log.warn("failed to append newline: {}", .{err}); + }; + ed.cursor_blink_start_ms = host.now_ms; + } } else { self.submitComment(host.now_ms); } From be98218fb0bb4f51e8d9aeec476b007aebcce881 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sun, 8 Feb 2026 09:21:24 +0100 Subject: [PATCH 07/10] fix(ui): prevent Escape long-hold from leaking key to terminal Two issues caused Escape to reach the terminal during a hold gesture: repeat KEY_DOWN events were not consumed while holding, and the KEY_UP was sent after the flash animation reset the consumed state. Now all Escape events are consumed while the gesture is active, and the gesture stays alive until key release so the consumed flag is preserved. --- src/ui/components/escape_hold.zig | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/src/ui/components/escape_hold.zig b/src/ui/components/escape_hold.zig index 9e9ae56..1962fd0 100644 --- a/src/ui/components/escape_hold.zig +++ b/src/ui/components/escape_hold.zig @@ -52,21 +52,19 @@ pub const EscapeHoldComponent = struct { switch (event.type) { c.SDL_EVENT_KEY_DOWN => { - if (event.key.key == c.SDLK_ESCAPE and !event.key.repeat) { - self.gesture.start(host.now_ms, esc_hold_total_ms); - self.first_frame.markTransition(); - return true; + if (event.key.key == c.SDLK_ESCAPE) { + if (!event.key.repeat) { + self.gesture.start(host.now_ms, esc_hold_total_ms); + self.first_frame.markTransition(); + } + return self.gesture.active; } }, c.SDL_EVENT_KEY_UP => { if (event.key.key == c.SDLK_ESCAPE) { - if (self.gesture.isComplete(host.now_ms) or self.gesture.consumed) { - self.gesture.stop(); - return true; - } - // quick tap: let main handle ESC to terminal + const was_consumed = self.gesture.consumed; self.gesture.stop(); - return false; + return was_consumed; } }, else => {}, @@ -86,11 +84,7 @@ pub const EscapeHoldComponent = struct { } if (self.gesture.consumed) { - const elapsed_since_complete = host.now_ms - (self.gesture.start_ms + esc_hold_total_ms); - const flash_duration_ms: i64 = 200; - if (elapsed_since_complete >= flash_duration_ms) { - self.gesture.stop(); - } + self.first_frame.markTransition(); } } From 4e67d22d66cdda68010b04de7f506ddf992c35f8 Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sun, 8 Feb 2026 09:50:27 +0100 Subject: [PATCH 08/10] fix(ui): prevent Esc indicator from staying visible after grid transition Move the canHandleEscapePress guard to only block KEY_DOWN (starting new gestures), not KEY_UP (cleanup). Previously, after a successful grid transition, canHandleEscapePress returned false for Grid mode, blocking the KEY_UP handler entirely and leaving the gesture active with the indicator stuck on screen. Also restrict KEY_UP handling to only run when a gesture is active to avoid spurious stop calls. --- src/ui/components/escape_hold.zig | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ui/components/escape_hold.zig b/src/ui/components/escape_hold.zig index 1962fd0..3fb1c10 100644 --- a/src/ui/components/escape_hold.zig +++ b/src/ui/components/escape_hold.zig @@ -48,11 +48,10 @@ pub const EscapeHoldComponent = struct { fn handleEvent(self_ptr: *anyopaque, host: *const types.UiHost, event: *const c.SDL_Event, _: *types.UiActionQueue) bool { const self: *EscapeHoldComponent = @ptrCast(@alignCast(self_ptr)); - if (!input.canHandleEscapePress(host.view_mode)) return false; - switch (event.type) { c.SDL_EVENT_KEY_DOWN => { if (event.key.key == c.SDLK_ESCAPE) { + if (!input.canHandleEscapePress(host.view_mode)) return false; if (!event.key.repeat) { self.gesture.start(host.now_ms, esc_hold_total_ms); self.first_frame.markTransition(); @@ -61,7 +60,7 @@ pub const EscapeHoldComponent = struct { } }, c.SDL_EVENT_KEY_UP => { - if (event.key.key == c.SDLK_ESCAPE) { + if (event.key.key == c.SDLK_ESCAPE and self.gesture.active) { const was_consumed = self.gesture.consumed; self.gesture.stop(); return was_consumed; From 33a3a79dd58b764b7933daa2b4f651e2d414ef7f Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sun, 8 Feb 2026 09:50:32 +0100 Subject: [PATCH 09/10] fix(ui): use process detection for agent startup instead of cell reading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal cell reading is unreliable for TUI agents like Claude Code that use Ink/React framework — prompt characters like ❯ store as codepoint 0x0000 in terminal cells despite being visually rendered. Switch to hasForegroundProcess() which reliably detects when the agent process has started, regardless of how it renders its UI. --- src/app/runtime.zig | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/src/app/runtime.zig b/src/app/runtime.zig index 4b7e359..ba3984c 100644 --- a/src/app/runtime.zig +++ b/src/app/runtime.zig @@ -98,25 +98,8 @@ fn highestSpawnedIndex(sessions: []const *SessionState) ?usize { return null; } -fn terminalHasAgentPrompt(session: *const SessionState) bool { - const terminal = session.terminal orelse return false; - const pages = terminal.screens.active.pages; - const cols = session.pty_size.ws_col; - const row = terminal.screens.active.cursor.y; - var col: usize = 0; - while (col < cols) : (col += 1) { - const list_cell = pages.getCell(.{ .active = .{ - .x = @intCast(col), - .y = @intCast(row), - } }) orelse continue; - const cp: u21 = if (list_cell.cell.content_tag == .codepoint) - list_cell.cell.content.codepoint - else - 0; - if (cp == ' ' or cp == 0) continue; - return cp == '>' or cp == 0x276F or cp == 0x203A; - } - return false; +fn agentProcessStarted(session: *const SessionState) bool { + return session.hasForegroundProcess(); } fn adjustedRenderHeightForMode(mode: app_state.ViewMode, render_height: c_int, ui_scale: f32, grid_rows: usize) c_int { @@ -1441,7 +1424,7 @@ pub fn run() !void { if (pending_comment_send) |pcs| { const prompt_ready = pcs.session < sessions.len and - terminalHasAgentPrompt(sessions[pcs.session]); + agentProcessStarted(sessions[pcs.session]); if (now >= pcs.send_after_ms or prompt_ready) { if (pcs.session < sessions.len) { sessions[pcs.session].sendInput(pcs.text) catch |err| { From b605649197f945ec106a67f5b782c79a9de1e96f Mon Sep 17 00:00:00 2001 From: Forketyfork Date: Sun, 8 Feb 2026 10:23:20 +0100 Subject: [PATCH 10/10] docs: update architecture for diff review comments feature Add ADR-013 documenting the pragmatic exception for synchronous I/O in UI overlays (git commands and small-file persistence). Update module boundary table to reflect new dependencies (ui/session_view_state in app layer, easeOutCubic in shared utilities), add diff_comments.json to the storage table, and note inline review comments in the diff viewer component description. --- docs/ARCHITECTURE.md | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d2df431..2dacd51 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -264,6 +264,7 @@ Renderer draws attention border (pulsing yellow / solid green) | Render cache | GPU textures per session | Cached terminal renders, epoch-invalidated | | config.toml | `~/.config/architect/config.toml` | User preferences (font, theme, UI flags) | | persistence.toml | `~/.config/architect/persistence.toml` | Runtime state (window pos, font size, terminal cwds) | +| diff_comments.json | `/.architect/diff_comments.json` | Per-repo inline diff review comments (unsent) | ### Exit Points @@ -280,7 +281,7 @@ Renderer draws attention border (pulsing yellow / solid green) |--------|---------------|----------------------------------|--------------| | `main.zig` | Thin entrypoint | `main()` | `app/runtime` | | `app/runtime.zig` | Application lifetime, frame loop, session spawning, config persistence | `run()`, frame loop internals | `platform/sdl`, `session/state`, `render/renderer`, `ui/root`, `config`, all `app/*` modules | -| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard, worktree commands | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()` | `geom`, `anim/easing`, `ui/types`, `colors`, `input/mapper`, `session/state`, `c` | +| `app/*` (app_state, layout, ui_host, grid_nav, grid_layout, input_keys, input_text, terminal_actions, worktree) | Application logic decomposed by concern: state enums, grid sizing, UI snapshot building, navigation, input encoding, clipboard, worktree commands | `ViewMode`, `AnimationState`, `SessionStatus`, `buildUiHost()`, `applyTerminalResize()`, `encodeKey()`, `paste()`, `clear()` | `geom`, `anim/easing`, `ui/types`, `ui/session_view_state`, `colors`, `input/mapper`, `session/state`, `c` | | `platform/sdl.zig` | SDL3 initialization, window management, HiDPI | `init()`, `createWindow()`, `createRenderer()` | `c` | | `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`) | @@ -293,10 +294,10 @@ Renderer draws attention border (pulsing yellow / solid green) | `ui/root.zig` | UI component registry, z-index dispatch, action drain | `UiRoot`, `register()`, `handleEvent()`, `update()`, `render()`, `needsFrame()` | `ui/component`, `ui/types` | | `ui/component.zig` | UI component vtable interface | `UiComponent`, `VTable` (handleEvent, update, render, hitTest, wantsFrame, deinit) | `ui/types`, `c` | | `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 | `SessionViewState` (selection, scroll offset, hover) | (none) | +| `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, 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` | -| 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()` | std, zig-toml, `c` | +| `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` | +| 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 @@ -409,3 +410,13 @@ Renderer draws attention border (pulsing yellow / solid green) - *Always render at full rate* -- rejected because it wastes CPU/GPU when nothing is changing, impacting battery life on laptops. - *SDL event injection* -- rejected because synthetic events pollute the event queue and complicate event handling logic. - **Date:** 2025 (UI system refinement) + +### ADR-013: Synchronous I/O in UI Overlays for Git and Small-File Persistence + +- **Decision:** UI overlay components may perform synchronous I/O on the main thread for two categories of operations: (1) running short-lived `git` commands (e.g., `git diff`, `git rev-parse`) whose output is needed immediately for rendering, and (2) reading/writing small per-repo data files (e.g., `/.architect/diff_comments.json`). +- **Context:** The diff overlay needs `git diff` output to render its content and persists inline review comments as a small JSON file. ADR-009 establishes that blocking I/O should go on a background thread, but these operations complete in single-digit milliseconds for typical repositories and small data files. Introducing a background thread with a callback-based rendering pipeline for each git command would add significant complexity (deferred rendering, loading states, race conditions with overlay visibility) for negligible latency improvement. +- **Constraints:** This exception applies only when the data is small and the command is fast. Large or potentially slow operations (e.g., network I/O, cloning, `git log` on deep histories) must still use the background thread pattern from ADR-009. +- **Alternatives considered:** + - *Background thread + queue for all git commands* -- deferred; would require deferred rendering with loading states in the overlay, adding complexity disproportionate to the latency risk. May be revisited if git operations become noticeably slow on large repositories. + - *Lazy/cached persistence* -- partially adopted; comments are only saved on overlay close and on comment submit, not on every keystroke. +- **Date:** 2025 (diff overlay inline comments)