From 902da833536fbec4e8e74c0e861dbffa2a8ce290 Mon Sep 17 00:00:00 2001 From: raeedz Date: Wed, 1 Jul 2026 16:50:29 -0700 Subject: [PATCH 1/9] Add syntax colors for variables, properties, and functions Editor theme, tokens, and commit diff highlighter previously rendered all identifiers in near-white text; now variables/properties get a soft light-blue and functions/calls get a warm gold, shared via new --syntax-* CSS tokens. --- src/design/cm6-theme.ts | 21 ++++++++++++++------- src/design/tokens.css | 13 +++++++++++++ src/git/diff-highlight.tsx | 14 ++++++++++---- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/src/design/cm6-theme.ts b/src/design/cm6-theme.ts index ee41d7d..ec05f18 100644 --- a/src/design/cm6-theme.ts +++ b/src/design/cm6-theme.ts @@ -40,6 +40,10 @@ const COLORS = { stateWarning: "var(--state-warning)", stateError: "var(--state-error)", stateInfoMuted: "color-mix(in oklch, var(--surface-1), var(--state-info) 18%)", + + syntaxVariable: "var(--syntax-variable)", + syntaxProperty: "var(--syntax-property)", + syntaxFunction: "var(--syntax-function)", }; export const cm6Theme = EditorView.theme( @@ -134,15 +138,18 @@ export const cm6Highlight = HighlightStyle.define([ { tag: t.paren, color: COLORS.textSecondary }, { tag: t.separator, color: COLORS.textTertiary }, - // Identifiers — primary text, no special color - { tag: t.variableName, color: COLORS.textPrimary }, - { tag: t.propertyName, color: COLORS.textPrimary }, + // Identifiers — soft light-blue. Lezer tags bare variables and + // function calls alike as `variableName`, so this is what gives the + // bulk of the code color instead of a wall of near-white text. + { tag: t.variableName, color: COLORS.syntaxVariable }, + { tag: t.definition(t.variableName), color: COLORS.syntaxVariable }, + { tag: t.propertyName, color: COLORS.syntaxProperty }, { tag: t.attributeName, color: COLORS.stateInfo }, - // Functions — medium weight, primary color - { tag: t.function(t.variableName), color: COLORS.textPrimary, fontWeight: "500" }, - { tag: t.function(t.propertyName), color: COLORS.textPrimary, fontWeight: "500" }, - { tag: t.macroName, color: COLORS.textPrimary, fontWeight: "500" }, + // Functions — warm gold, medium weight + { tag: t.function(t.variableName), color: COLORS.syntaxFunction, fontWeight: "500" }, + { tag: t.function(t.propertyName), color: COLORS.syntaxFunction, fontWeight: "500" }, + { tag: t.macroName, color: COLORS.syntaxFunction, fontWeight: "500" }, // Keywords — info blue, distinct from accent { tag: t.keyword, color: COLORS.stateInfo, fontWeight: "500" }, diff --git a/src/design/tokens.css b/src/design/tokens.css index 8caf752..09ea32b 100644 --- a/src/design/tokens.css +++ b/src/design/tokens.css @@ -109,6 +109,19 @@ --diff-change-bg: oklch(32% 0.13 85 / 0.40); --diff-change-fg: oklch(86% 0.18 85); + /* Syntax palette — shared by the editor theme (cm6-theme.ts) and the + diff highlighter (diff-highlight.tsx), which mirror each other. + Identifiers — variables, function calls, and properties — are the + bulk of any code diff, and Lezer tags them all as plain + `variableName`/`propertyName`. Left uncolored they fall back to + near-white --text-primary, so a diff reads as a wall of white. + A soft light-blue for identifiers and a warm gold for functions + give code real color while staying calm against the cool-dark + surface. */ + --syntax-variable: oklch(83% 0.055 245); + --syntax-property: oklch(83% 0.055 245); + --syntax-function: oklch(87% 0.11 92); + /* Modal backdrop */ --backdrop: oklch(0% 0 0 / 0.55); diff --git a/src/git/diff-highlight.tsx b/src/git/diff-highlight.tsx index 0ffde29..d720834 100644 --- a/src/git/diff-highlight.tsx +++ b/src/git/diff-highlight.tsx @@ -65,12 +65,18 @@ const HL = tagHighlighter([ { tag: t.className, class: "color:var(--accent)" }, { tag: t.namespace, class: "color:var(--accent)" }, - { tag: t.function(t.variableName), class: "font-weight:500" }, - { tag: t.function(t.propertyName), class: "font-weight:500" }, - { tag: t.macroName, class: "font-weight:500" }, + // Identifiers. Lezer tags bare variables AND function calls alike as + // `variableName`, so this rule is what colors the bulk of the code — + // without it everything here falls back to near-white --text-primary. + { tag: t.variableName, class: "color:var(--syntax-variable)" }, + { tag: t.definition(t.variableName), class: "color:var(--syntax-variable)" }, + + { tag: t.function(t.variableName), class: "color:var(--syntax-function);font-weight:500" }, + { tag: t.function(t.propertyName), class: "color:var(--syntax-function);font-weight:500" }, + { tag: t.macroName, class: "color:var(--syntax-function);font-weight:500" }, { tag: t.attributeName, class: "color:var(--state-info)" }, - { tag: t.propertyName, class: "color:var(--text-primary)" }, + { tag: t.propertyName, class: "color:var(--syntax-property)" }, { tag: t.tagName, class: "color:var(--state-error)" }, { tag: t.angleBracket, class: "color:var(--text-tertiary)" }, From cc672fda1459e2897b6da9c52788786897499268 Mon Sep 17 00:00:00 2001 From: raeedz Date: Thu, 2 Jul 2026 21:04:29 -0700 Subject: [PATCH 2/9] Add alt-screen scroll glue for selections via content matching Replace the wheel-count-based `term_native_selection_scrolled` command with `detect_scroll_shift`, which diffs consecutive grid snapshots by row text to measure the actual scroll offset and re-glues the active selection accordingly. This tracks apps (vim, less, htop) that scroll more than one row per wheel notch, where the old heuristic drifted. --- src-tauri/src/lib.rs | 2 - src-tauri/src/warp_term.rs | 237 ++++++++++++++++-- src/terminal/BlockTerminal.tsx | 24 +- .../src/elements/selectable_area.rs | 12 + 4 files changed, 241 insertions(+), 34 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bc2d9c2..eb6c253 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -300,8 +300,6 @@ pub fn run() { #[cfg(target_os = "macos")] warp_term::term_native_selection_text, #[cfg(target_os = "macos")] - warp_term::term_native_selection_scrolled, - #[cfg(target_os = "macos")] warp_term::term_native_set_viewport, #[cfg(target_os = "macos")] warp_term::term_native_link_at, diff --git a/src-tauri/src/warp_term.rs b/src-tauri/src/warp_term.rs index f531058..398e943 100644 --- a/src-tauri/src/warp_term.rs +++ b/src-tauri/src/warp_term.rs @@ -871,6 +871,87 @@ fn is_zsh_eol_marker(row: &RowSnapshot) -> bool { seen } +/// Concatenated glyph text of a row with trailing blanks trimmed — the identity +/// we match on when measuring how far an alt-screen app scrolled. We compare +/// TEXT (not the full styled spans) so a pager re-coloring a line (e.g. moving +/// its highlighted current line, or a cursor landing on it) doesn't defeat the +/// match. +fn row_text(row: &RowSnapshot) -> String { + let mut s = String::new(); + for sp in &row.spans { + s.push_str(&sp.text); + } + s.trim_end().to_string() +} + +/// Measure how many rows an alt-screen app scrolled between two consecutive grid +/// snapshots, by content matching. Returns `k` such that the new grid shows, at +/// row `i`, what the old grid had at row `i + k`: +/// - `k > 0` → content moved UP by k rows (scrolled toward newer / down) +/// - `k < 0` → content moved DOWN by k rows (scrolled toward older / up) +/// - `0` → no clear scroll (partial repaint, spinner tick, cursor blink, +/// a page swap, or genuinely nothing moved) +/// +/// This is the source of truth for gluing a selection to alt-screen text, and it +/// replaces the old "assume the app scrolls one row per wheel notch" guess — +/// which drifts on any app that scrolls several rows per notch (vim's default, +/// most pagers). Because it reads the app's ACTUAL response it's correct +/// regardless of the app's wheel-to-rows ratio. +/// +/// Deliberately conservative: it reports a non-zero shift only when a clear +/// majority of the NON-BLANK rows line up at exactly one offset AND that offset +/// explains more rows than staying put (k = 0). A one-line spinner update or a +/// single streamed character leaves k = 0 unbeaten, so a completed selection is +/// never nudged by a non-scroll repaint — the exact "jumpy / stuck selection" +/// artifact a naive always-shift approach produces. +fn detect_scroll_shift(old: &[RowSnapshot], new: &[RowSnapshot]) -> i32 { + let n = old.len().min(new.len()); + if n < 4 { + return 0; // too little signal to be confident + } + let o: Vec = old.iter().take(n).map(row_text).collect(); + let e: Vec = new.iter().take(n).map(row_text).collect(); + + // Count indices where new[i] equals old[i + k], ignoring blank rows (a blank + // line matches every other blank line and would inflate every offset). + let score = |k: i32| -> usize { + let mut c = 0usize; + for i in 0..n { + let j = i as i32 + k; + if j < 0 || j as usize >= n { + continue; + } + if !e[i].is_empty() && e[i] == o[j as usize] { + c += 1; + } + } + c + }; + + let base = score(0); // non-blank rows still in place + let mut best_k = 0i32; + let mut best = base; + let range = (n as i32) - 1; + for k in -range..=range { + if k == 0 { + continue; + } + let s = score(k); + if s > best { + best = s; + best_k = k; + } + } + + // Require the winning offset to be genuinely dominant: it must line up a solid + // block of rows and clearly beat the in-place score, else treat it as noise. + if best_k != 0 && best >= 3 && best > base + 1 { + best_k + } else { + 0 + } +} + /// How many leading rows of the live grid to render: trims trailing blank rows /// (keeping through the cursor row) so an idle / just-finished shell screen sits /// compactly above the input instead of padding the transcript with blanks. @@ -1580,7 +1661,26 @@ pub fn attach(app: &tauri::AppHandle) { // Route the frame to whichever pane mirrors this pty (main or side). if let Some(p) = pane_for_pty(pty_id) { let mut g = p.grid.lock().unwrap_or_else(|e| e.into_inner()); + // Alt-screen scroll-glue: an alt-screen app (git log / less / man / + // vim / htop) repaints its grid in place when it scrolls, so a + // completed selection anchored to fixed grid coordinates would slide + // off the text it was started on. Measure how far the grid ACTUALLY + // scrolled (content match — robust to the app's rows-per-wheel-notch, + // which the old React-side line-count guess got wrong) and shift the + // selection to track it. Gated to when something is actually selected + // AND the app owns the screen: the normal shell/inline-agent + // transcript glues for free (its SelectableArea lives inside the + // ClippedScrollable, whose scroll translation already moves the + // selection with the content), so we must NOT double-shift it here. + let detect = frame.alt_screen && p.sel.has_selection(); + let old_rows = if detect { g.rows.clone() } else { Vec::new() }; g.apply_frame(frame); + if detect { + let k = detect_scroll_shift(&old_rows, &g.rows); + if k != 0 { + p.sel.shift_relative_y(-(k as f32) * LINE_PX); + } + } } let _ = app_for_sink.run_on_main_thread(|| { warpui::platform::poke_embedded_redraw(); @@ -1980,24 +2080,6 @@ pub fn term_native_mouse( } } -/// Tauri command: the alt-screen agent's content scrolled by `delta_lines` -/// (the same signed line count just sent through `term_native_wheel`; -/// positive = toward newer/down). The app repaints its grid in place, so a -/// selection anchored to grid coordinates would highlight whatever text -/// scrolled under it. Shift the stored selection bounds by the distance the -/// content moved (scrolling up by N lines moves the text DOWN N rows → -/// +N·LINE_PX) so the highlight stays glued to the text it was started on, -/// Warp-style. Best-effort: assumes the app scrolls one row per wheel line -/// (true for claude/codex and every pager we route here). -#[tauri::command] -pub fn term_native_selection_scrolled(pane_key: String, delta_lines: i32) { - let p = pane(&pane_key); - p.sel.shift_relative_y(-(delta_lines as f32) * LINE_PX); - if let Some(app) = APP_HANDLE.get() { - let _ = app.run_on_main_thread(|| warpui::platform::poke_embedded_redraw()); - } -} - /// Tauri command: the latest selected transcript text (cached by the /// `SelectableArea` selection handler), or `None` if nothing is selected. React /// reads this on Cmd+C in the shell and writes it to the clipboard via the @@ -2054,6 +2136,125 @@ pub fn term_native_set_viewport(pane_key: String, top: f64, height: f64) { } } +#[cfg(test)] +mod scroll_shift_tests { + use super::*; + + fn plain_span(text: &str) -> Span { + Span { + text: text.to_string(), + fg: "var(--text-primary)".into(), + bg: "var(--surface-0)".into(), + bold: false, + italic: false, + underline: false, + inverse: false, + dim: false, + strikeout: false, + link: None, + } + } + fn rows(lines: &[&str]) -> Vec { + lines + .iter() + .map(|l| RowSnapshot { + spans: vec![plain_span(l)], + }) + .collect() + } + + #[test] + fn scroll_down_shifts_content_up() { + // Ten distinct rows; the app scrolls DOWN by 3 (content moves up 3, three + // fresh rows appear at the bottom). new[i] == old[i+3] for the retained + // rows, so the measured shift is +3. + let old = rows(&[ + "r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", + ]); + let new = rows(&[ + "r3", "r4", "r5", "r6", "r7", "r8", "r9", "n7", "n8", "n9", + ]); + assert_eq!(detect_scroll_shift(&old, &new), 3); + } + + #[test] + fn scroll_up_shifts_content_down() { + // The app scrolls UP by 2 (content moves down 2, two older rows appear at + // the top). new[i] == old[i-2] → measured shift is -2. + let old = rows(&[ + "r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8", "r9", + ]); + let new = rows(&[ + "p0", "p1", "r0", "r1", "r2", "r3", "r4", "r5", "r6", "r7", + ]); + assert_eq!(detect_scroll_shift(&old, &new), -2); + } + + #[test] + fn identical_grids_report_no_scroll() { + let g = rows(&["a", "b", "c", "d", "e", "f", "g", "h"]); + assert_eq!(detect_scroll_shift(&g, &g), 0); + } + + #[test] + fn spinner_tick_reports_no_scroll() { + // Only one row changes (a spinner glyph / streamed char). Staying put + // explains far more rows than any shift, so no shift is reported — this + // is what keeps a completed selection from jumping on a non-scroll frame. + let old = rows(&["a", "b", "c", "d", "e", "f", "g", "loading |"]); + let new = rows(&["a", "b", "c", "d", "e", "f", "g", "loading /"]); + assert_eq!(detect_scroll_shift(&old, &new), 0); + } + + #[test] + fn full_page_swap_reports_no_scroll() { + // A page jump replaces every row with unrelated content — no offset lines + // anything up, so we conservatively report no scroll rather than guess. + let old = rows(&["a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7"]); + let new = rows(&["z0", "z1", "z2", "z3", "z4", "z5", "z6", "z7"]); + assert_eq!(detect_scroll_shift(&old, &new), 0); + } + + #[test] + fn blank_rows_do_not_inflate_a_false_shift() { + // A grid that is mostly blank with a couple of content rows must not + // report a shift just because the blank rows "match" at every offset. + let old = rows(&["", "", "hello", "world", "", "", "", ""]); + let new = rows(&["", "", "hello", "world", "", "", "", ""]); + assert_eq!(detect_scroll_shift(&old, &new), 0); + } + + #[test] + fn ignores_color_only_changes() { + // A pager re-coloring its current line (same text, different style) is not + // a scroll. row_text compares glyphs only, so this stays at 0. + let old = vec![ + RowSnapshot { spans: vec![plain_span("line one")] }, + RowSnapshot { spans: vec![plain_span("line two")] }, + RowSnapshot { spans: vec![plain_span("line three")] }, + RowSnapshot { spans: vec![plain_span("line four")] }, + RowSnapshot { spans: vec![plain_span("line five")] }, + ]; + let mut recolored = plain_span("line three"); + recolored.inverse = true; + let new = vec![ + RowSnapshot { spans: vec![plain_span("line one")] }, + RowSnapshot { spans: vec![plain_span("line two")] }, + RowSnapshot { spans: vec![recolored] }, + RowSnapshot { spans: vec![plain_span("line four")] }, + RowSnapshot { spans: vec![plain_span("line five")] }, + ]; + assert_eq!(detect_scroll_shift(&old, &new), 0); + } + + #[test] + fn tiny_grids_bail_out() { + let old = rows(&["a", "b", "c"]); + let new = rows(&["b", "c", "d"]); + assert_eq!(detect_scroll_shift(&old, &new), 0); + } +} + #[cfg(test)] mod link_tests { use super::*; diff --git a/src/terminal/BlockTerminal.tsx b/src/terminal/BlockTerminal.tsx index 81eee20..c0b2aa3 100644 --- a/src/terminal/BlockTerminal.tsx +++ b/src/terminal/BlockTerminal.tsx @@ -963,12 +963,10 @@ export function BlockTerminal({ invoke("term_native_wheel", { id: ptyId, deltaLines: lines, col, row }).catch( () => {}, ); - // Keep any native selection glued to its text: the app repaints its grid - // in place when it scrolls, so shift the stored selection anchors by the - // distance the content moved. No-op when nothing is selected. - invoke("term_native_selection_scrolled", { paneKey, deltaLines: lines }).catch( - () => {}, - ); + // Any native selection stays glued to its text without our help: the Rust + // frame sink measures how far the app actually scrolled (content match) + // and shifts the selection anchors to match. We used to guess the distance + // here from `lines`, which drifted whenever the app scrolled ≠1 row/notch. // Drain a fast flick across subsequent frames (momentum) rather than one // big jump — smooth deceleration. if (Math.abs(accumPx) >= LINE_PX) raf = requestAnimationFrame(flush); @@ -1103,11 +1101,12 @@ export function BlockTerminal({ if (delta === 0) return; // back inside the safe zone — stop the loop if (altScreen) { // Alt-screen AGENT (claude/codex on the alt screen): the app owns its - // scroll-back, so page it with the same wheel encoding the wheel - // bridge uses, then shift the native selection anchors by the distance - // the content moved (term_native_selection_scrolled) so the highlight - // stays glued to the text it was started on instead of whatever - // scrolled under it. Whole lines only; the px remainder carries. + // scroll-back, so page it with the same wheel encoding the wheel bridge + // uses. The selection stays glued on its own — the Rust frame sink + // measures how far the app actually scrolled (content match) and shifts + // the anchors to match, so the highlight tracks the text it was started + // on instead of whatever scrolled under it. Whole lines only; the px + // remainder carries. wheelAccumPx += delta; const lines = Math.trunc(wheelAccumPx / DRAG_LINE_PX); if (lines !== 0) { @@ -1117,9 +1116,6 @@ export function BlockTerminal({ invoke("term_native_wheel", { id: ptyId, deltaLines: lines, col, row }).catch( () => {}, ); - invoke("term_native_selection_scrolled", { paneKey, deltaLines: lines }).catch( - () => {}, - ); } } else { invoke("term_native_scroll", { paneKey, deltaPx: delta }).catch(() => {}); diff --git a/vendor/crates/warpui_core/src/elements/selectable_area.rs b/vendor/crates/warpui_core/src/elements/selectable_area.rs index 003ab31..7d8fc6f 100644 --- a/vendor/crates/warpui_core/src/elements/selectable_area.rs +++ b/vendor/crates/warpui_core/src/elements/selectable_area.rs @@ -200,6 +200,18 @@ impl SelectionHandle { .is_selecting } + /// Whether a selection currently exists (a head anchor has been set) — + /// whether or not it is still being actively dragged. Used by the terminal + /// to skip the alt-screen scroll-glue bookkeeping entirely when there is + /// nothing selected. + pub fn has_selection(&self) -> bool { + self.selection + .lock() + .expect("Should not be poisoned.") + .head + .is_some() + } + pub fn clear(&self) { self.selection .lock() From e49c06f375056560d33d726d136ca37b5a47158e Mon Sep 17 00:00:00 2001 From: raeedz Date: Fri, 3 Jul 2026 21:39:54 -0700 Subject: [PATCH 3/9] Add fix-this-hunk workflow to diff view Lets users attach change requests to individual diff hunks and send them as one composed prompt to the worktree's agent terminal. --- src/git/AllChangesView.tsx | 4 + src/git/DiffFix.test.ts | 45 +++ src/git/DiffFix.tsx | 552 +++++++++++++++++++++++++++++++++++++ src/git/DiffView.tsx | 218 +++++++++++++-- 4 files changed, 802 insertions(+), 17 deletions(-) create mode 100644 src/git/DiffFix.test.ts create mode 100644 src/git/DiffFix.tsx diff --git a/src/git/AllChangesView.tsx b/src/git/AllChangesView.tsx index 534723a..cafdbd0 100644 --- a/src/git/AllChangesView.tsx +++ b/src/git/AllChangesView.tsx @@ -4,6 +4,7 @@ import { CaretDown } from "@phosphor-icons/react"; import { git } from "@/lib/git"; import { DiffBody } from "./DiffView"; import { DiffAskOverlay, reconstructDiffContext } from "./DiffAsk"; +import { DiffFixBar, DiffFixProvider } from "./DiffFix"; import { parseUnifiedDiff, type DiffLine } from "./diff-parse"; /** @@ -76,6 +77,7 @@ export function AllChangesView({ projectPath }: { projectPath: string }) { }, [sections]); return ( + )} + + ); } diff --git a/src/git/DiffFix.test.ts b/src/git/DiffFix.test.ts new file mode 100644 index 0000000..841499d --- /dev/null +++ b/src/git/DiffFix.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test"; +import { composeFixPrompt, type HunkRef } from "./DiffFix"; + +const hunk = (over: Partial): HunkRef => ({ + id: "src/foo.ts#0", + file: "src/foo.ts", + label: "", + snippet: "@@ -1,2 +1,2 @@\n-old\n+new", + ...over, +}); + +describe("composeFixPrompt", () => { + it("pairs each request with its diff snippet inside a fenced block", () => { + const prompt = composeFixPrompt([ + { ref: hunk({ label: "fn handleClick" }), text: "rename to onSelect" }, + ]); + expect(prompt).toContain("### Change 1 — src/foo.ts (in fn handleClick)"); + expect(prompt).toContain("```diff\n@@ -1,2 +1,2 @@\n-old\n+new\n```"); + expect(prompt).toContain("Requested: rename to onSelect"); + expect(prompt).toContain("Make these edits now."); + }); + + it("omits the scope suffix when the hunk has no label", () => { + const prompt = composeFixPrompt([{ ref: hunk({}), text: "handle null" }]); + expect(prompt).toContain("### Change 1 — src/foo.ts\n"); + expect(prompt).not.toContain("(in "); + }); + + it("numbers multiple changes in order", () => { + const prompt = composeFixPrompt([ + { ref: hunk({ id: "a#0", file: "a.ts" }), text: "first" }, + { ref: hunk({ id: "b#0", file: "b.ts" }), text: "second" }, + ]); + expect(prompt).toContain("### Change 1 — a.ts"); + expect(prompt).toContain("### Change 2 — b.ts"); + expect(prompt.indexOf("Change 1")).toBeLessThan(prompt.indexOf("Change 2")); + }); + + it("trims surrounding whitespace from the user's request", () => { + const prompt = composeFixPrompt([ + { ref: hunk({}), text: " spaced out \n" }, + ]); + expect(prompt).toContain("Requested: spaced out"); + }); +}); diff --git a/src/git/DiffFix.tsx b/src/git/DiffFix.tsx new file mode 100644 index 0000000..b5fdd50 --- /dev/null +++ b/src/git/DiffFix.tsx @@ -0,0 +1,552 @@ +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { invoke } from "@tauri-apps/api/core"; +import { AnimatePresence, motion } from "motion/react"; +import { MagicWand, Minus, NotePencil, Plus, X } from "@phosphor-icons/react"; +import { useAppDispatch, useAppState } from "@/state/AppState"; +import { useToast } from "@/primitives/Toast"; +import type { AppState, TerminalTab, Worktree } from "@/state/types"; + +/** + * "Fix this hunk" — a review-and-delegate layer over the working diff. + * + * Each edited block in a working-tree diff (a contiguous run of `+`/`-` + * lines) grows a `+` button in the left gutter; clicking it opens an + * inline box under that block where the user types what they want + * changed about that part of the code. Comments accumulate across blocks + * and files, and a floating "Fix" bar composes them into one prompt — + * each request paired with the exact diff snippet it refers to — and + * pastes it straight into the worktree's agent terminal, which then goes + * and implements them. + * + * The plumbing is a React context: `DiffFixProvider` owns the comment + * state and the target worktree's cwd; `DiffBody` reads the context to + * decide whether to render `+` buttons at all (so historical commit + * diffs, which mount without a provider, stay read-only); `DiffFixBar` + * reads it to build and send the prompt. + */ + +/** A single hunk the user can attach a change request to. */ +export interface HunkRef { + /** Stable within one diff render: `${file}#${ordinal}`. */ + id: string; + file: string; + /** Enclosing scope git tacked onto the `@@` header, if any. */ + label: string; + /** The hunk rebuilt as a unified-diff snippet, handed to the agent. */ + snippet: string; +} + +interface DiffFixApi { + isOpen: (id: string) => boolean; + hasText: (id: string) => boolean; + /** Open a box for this hunk (idempotent — keeps existing text). */ + openBox: (ref: HunkRef) => void; + /** Close a box and drop its text. */ + closeBox: (id: string) => void; + getText: (id: string) => string; + setText: (id: string, text: string) => void; + /** Open boxes with non-empty text, in file+hunk order. */ + entries: () => Array<{ ref: HunkRef; text: string }>; + /** Count of non-empty comments — drives the Fix bar. */ + count: number; + clear: () => void; + /** Worktree checkout dir this diff belongs to. */ + cwd: string; +} + +const DiffFixContext = createContext(null); + +/** Diff-fix API for the enclosing view, or null when there is no + * provider (e.g. a historical commit diff — read-only, no `+`). */ +export function useDiffFix(): DiffFixApi | null { + return useContext(DiffFixContext); +} + +export function DiffFixProvider({ + cwd, + children, +}: { + cwd: string; + children: ReactNode; +}) { + // `open` maps hunk id → its ref (so we can rebuild the prompt without + // re-deriving snippets); `text` maps hunk id → the user's request. + const [open, setOpen] = useState>({}); + const [text, setText] = useState>({}); + + const api = useMemo(() => { + const orderKey = (ref: HunkRef) => { + const hash = ref.id.lastIndexOf("#"); + const ord = hash >= 0 ? Number(ref.id.slice(hash + 1)) : 0; + return [ref.file, Number.isFinite(ord) ? ord : 0] as const; + }; + const activeRefs = Object.values(open).filter( + (ref) => (text[ref.id]?.trim().length ?? 0) > 0, + ); + return { + isOpen: (id) => id in open, + hasText: (id) => id in open && (text[id]?.trim().length ?? 0) > 0, + openBox: (ref) => + setOpen((o) => (o[ref.id] ? o : { ...o, [ref.id]: ref })), + closeBox: (id) => { + setOpen((o) => { + if (!(id in o)) return o; + const { [id]: _drop, ...rest } = o; + return rest; + }); + setText((t) => { + if (!(id in t)) return t; + const { [id]: _drop, ...rest } = t; + return rest; + }); + }, + getText: (id) => text[id] ?? "", + setText: (id, value) => setText((s) => ({ ...s, [id]: value })), + entries: () => + activeRefs + .slice() + .sort((a, b) => { + const [fa, oa] = orderKey(a); + const [fb, ob] = orderKey(b); + return fa === fb ? oa - ob : fa < fb ? -1 : 1; + }) + .map((ref) => ({ ref, text: text[ref.id] ?? "" })), + count: activeRefs.length, + clear: () => { + setOpen({}); + setText({}); + }, + cwd, + }; + }, [open, text, cwd]); + + return ( + {children} + ); +} + +/* ------------------------------------------------------------------ + `+` button — sits in the left gutter beside each change block. + ------------------------------------------------------------------ */ + +export function HunkAddButton({ + hunkRef, + fix, +}: { + hunkRef: HunkRef; + fix: DiffFixApi; +}) { + const active = fix.isOpen(hunkRef.id); + const commented = fix.hasText(hunkRef.id); + const lit = active || commented; + const [hover, setHover] = useState(false); + return ( + + ); +} + +/* ------------------------------------------------------------------ + Inline comment box — rendered in its own row under the hunk. + ------------------------------------------------------------------ */ + +export function HunkCommentBox({ + hunkRef, + fix, +}: { + hunkRef: HunkRef; + fix: DiffFixApi; +}) { + const ref = useRef(null); + const value = fix.getText(hunkRef.id); + + // Focus on mount; auto-grow to fit content. + useEffect(() => { + const el = ref.current; + if (!el) return; + el.focus(); + el.setSelectionRange(el.value.length, el.value.length); + }, []); + useEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 160)}px`; + }, [value]); + + return ( + + +