diff --git a/src-tauri/src/flat_term.rs b/src-tauri/src/flat_term.rs index 0553f0a..97da50f 100644 --- a/src-tauri/src/flat_term.rs +++ b/src-tauri/src/flat_term.rs @@ -272,6 +272,10 @@ fn take_top_row(grid: &mut ActiveGrid) -> Vec { grid.cells[grid.scroll_top].clone() } +/// Scrollback row cap for FlatTerm's primary-grid history. Finite by +/// design — see the comment in `FlatTerm::new`. +const SCROLLBACK_CAP_ROWS: usize = 100_000; + /// FlatTerm — the full terminal model. Owns: /// - `primary` active grid (the default screen) /// - `alt` active grid (swapped in on DECSET 1049) @@ -315,18 +319,22 @@ impl FlatTerm { Self { primary: ActiveGrid::new(cols, rows), alt: ActiveGrid::new(cols, rows), - // Unbounded scrollback — every row the agent ever produces - // stays accessible. FlatStorage::with_capacity is safe with - // usize::MAX because it decouples the cap from the initial - // Vec allocation; storage grows dynamically as rows arrive. - scrollback: FlatStorage::with_capacity(usize::MAX), + // Bounded scrollback. FlatStorage::with_capacity decouples + // the cap from the initial Vec allocation, so a large cap + // costs nothing until rows actually arrive — but the cap + // must be FINITE before FlatTerm replaces alacritty: + // usize::MAX here meant every agent pane retained every + // row it ever produced for the app's lifetime. 100k rows + // is days of continuous agent output; older rows live in + // the persisted block history, not the live grid. + scrollback: FlatStorage::with_capacity(SCROLLBACK_CAP_ROWS), use_alt: false, app_cursor: false, bracketed_paste: false, line_wrap: true, saved_cursor_for_swap: None, tab_stops, - scrollback_cap: usize::MAX, + scrollback_cap: SCROLLBACK_CAP_ROWS, } } diff --git a/src-tauri/src/persistence/mod.rs b/src-tauri/src/persistence/mod.rs index 11080c9..e2d9d81 100644 --- a/src-tauri/src/persistence/mod.rs +++ b/src-tauri/src/persistence/mod.rs @@ -35,11 +35,12 @@ //! the next slice (add a v2 migration + a new `Event::Snapshot` //! variant that fills those tables in a transaction, then drop the //! blob). -//! - **Block history persistence (Warp-parity).** Closed blocks are -//! persisted in full and never evicted by count, so terminal history -//! is never cut off across restarts. `load_blocks` restores the full -//! transcript; the native renderer virtualizes painting so deep -//! history does not make each frame proportional to its row count. +//! - **Block history persistence.** Closed blocks persist across +//! restarts, capped per pty at `BLOCK_DISK_CAP` (writer.rs, 2× the +//! restore window) — rows past the cap were unreachable by any code +//! path (`load_blocks` is the table's only reader) and just grew the +//! DB file forever. `load_blocks` windows the most-recent rows for +//! fast restore. //! - **No graceful shutdown.** The writer thread relies on macOS //! tearing it down at app exit; WAL recovers any half-finished //! transaction on next launch. If we add long-running async writes @@ -115,14 +116,16 @@ pub struct SavedBlock { pub duration_ms: Option, } -/// Return every persisted block for a pty_id in insertion order -/// (oldest first), matching how the frontend's `sessionMemory.blocks` -/// array is ordered. There used to be a 500-block read window here with -/// a promise that older rows would page in on scroll-back, but no paging -/// path existed; the 501st-oldest block was therefore permanently -/// unreachable after a restart. The render path already virtualizes -/// deep transcripts, so restoring the source of truth in full is both -/// correct and bounded at paint time. Returns an empty vec for unknown +/// How many of the most-recent blocks `load_blocks` returns on restore. +/// Disk retains up to `BLOCK_DISK_CAP` (2× this) per pty — see +/// writer.rs. Kept in sync with the front-end's `MAX_BLOCKS` in +/// `sessionMemory.ts`. +const HISTORY_LOAD_WINDOW: i64 = 500; + +/// Return the most-recent `HISTORY_LOAD_WINDOW` persisted blocks for a +/// pty_id in insertion order (oldest first), matching how the +/// frontend's `sessionMemory.blocks` array is ordered. Older blocks +/// stay on disk for scroll-back. Returns an empty vec for unknown /// pty_ids — no error. pub fn load_blocks( db_path: &std::path::Path, @@ -132,13 +135,19 @@ pub fn load_blocks( .map_err(|e| format!("open RO at {}: {e}", db_path.display()))?; let mut stmt = conn .prepare( + // Window to the most-recent rows (newest-first inner, then + // re-sorted oldest-first). Restore stays fast even when a + // pty has accumulated huge history; older blocks remain on + // disk and page in on scroll-back. "SELECT block_id, input, transcript, block_rows, \ - exit_code, cwd, duration_ms \ - FROM blocks WHERE pty_id = ?1 ORDER BY id ASC", + exit_code, cwd, duration_ms FROM (\ + SELECT * FROM blocks WHERE pty_id = ?1 \ + ORDER BY id DESC LIMIT ?2\ + ) ORDER BY id ASC", ) .map_err(|e| format!("prepare load_blocks: {e}"))?; let rows = stmt - .query_map(rusqlite::params![pty_id], |row| { + .query_map(rusqlite::params![pty_id, HISTORY_LOAD_WINDOW], |row| { let rows_text: String = row.get(3)?; // Stored as a known-shape JSON literal we wrote ourselves // last save — parse failures here are real corruption and @@ -383,14 +392,15 @@ mod tests { } #[test] - fn history_is_retained_and_restored_in_full() { + fn history_is_retained_on_disk_and_load_is_windowed() { let (_dir, path) = fresh_db(); let conn = db::open_rw(&path).unwrap(); let w = writer::start(conn, path.clone()); - // Insert past the old 500-block load window. Both storage and - // restore must remain complete: keeping old rows in SQLite is - // not useful if the UI can never load or scroll to them. + // Insert past the load window but under the disk cap + // (`BLOCK_DISK_CAP` = 1000). Nothing is evicted on save yet — the + // read path windows instead, so restore stays fast while recent + // history remains complete on disk. const WINDOW: i64 = 500; for i in 1..=(WINDOW + 5) { w.save_block(mk_block(i, &format!("cmd-{i}"))); @@ -401,7 +411,7 @@ mod tests { .unwrap_or(false) }); - // Every row is retained on disk — history is never cut off. + // All 505 rows are still on disk (below the 1000-row disk cap). let total: i64 = db::open_ro(&path) .unwrap() .query_row( @@ -410,12 +420,12 @@ mod tests { |r| r.get(0), ) .unwrap(); - assert_eq!(total, WINDOW + 5, "history must be retained in full"); + assert_eq!(total, WINDOW + 5, "recent history must be retained on disk"); - // load_blocks returns the entire transcript, oldest-first. + // ...but load_blocks returns only the most-recent WINDOW, oldest-first. let blocks = load_blocks(&path, "pty-A").unwrap(); - assert_eq!(blocks.len(), (WINDOW + 5) as usize); - assert_eq!(blocks[0].block_id, 1); + assert_eq!(blocks.len(), WINDOW as usize); + assert_eq!(blocks[0].block_id, 6); // 1..=5 fall outside the window assert_eq!(blocks.last().unwrap().block_id, WINDOW + 5); } diff --git a/src-tauri/src/persistence/writer.rs b/src-tauri/src/persistence/writer.rs index 8f126dd..7618b5e 100644 --- a/src-tauri/src/persistence/writer.rs +++ b/src-tauri/src/persistence/writer.rs @@ -34,6 +34,12 @@ use rusqlite::Connection; /// queue." const CAPACITY: usize = 1024; +/// Per-pty on-disk block cap: 2× the restore window +/// (`HISTORY_LOAD_WINDOW = 500` in `super::mod`), so restore always +/// has a full window even mid-eviction, while the table stops growing +/// without bound. Applied on every SaveBlock insert. +const BLOCK_DISK_CAP: i64 = 1000; + /// Messages the writer thread can process. #[derive(Debug)] enum Event { @@ -277,9 +283,21 @@ fn apply( now, ], )?; - // Warp-parity: never evict by count. Full block history is - // retained on disk and restored in full; the native painter - // virtualizes deep transcripts. + // Per-pty eviction: keep the newest BLOCK_DISK_CAP rows. + // Restore only ever reads the most-recent + // HISTORY_LOAD_WINDOW (500) blocks per pty, so rows past + // 2× that window are unreachable by any code path — they + // only grew the DB file forever (full transcript + JSON + // grid per block, per pty, across restarts). The + // `blocks_by_pty (pty_id, id)` index makes both the + // subquery and the delete cheap. + tx.execute( + "DELETE FROM blocks WHERE pty_id = ?1 AND id NOT IN (\ + SELECT id FROM blocks WHERE pty_id = ?1 \ + ORDER BY id DESC LIMIT ?2\ + )", + rusqlite::params![p.pty_id, BLOCK_DISK_CAP], + )?; tx.commit()?; } Event::ForgetPty(pty_id) => { diff --git a/src-tauri/src/pr.rs b/src-tauri/src/pr.rs index 789713d..a420659 100644 --- a/src-tauri/src/pr.rs +++ b/src-tauri/src/pr.rs @@ -92,11 +92,28 @@ pub async fn pr_draft( return Err(format!("cwd does not exist: {cwd}")); } - // Gather context: staged + unstaged diff (truncated) and the last - // few commit subjects. + // The PR describes EVERYTHING that will land on the base branch — + // not just what's dirty in the working tree right now. A branch + // whose work is already committed (and pushed) has an empty + // `git diff`, so relying on that alone made the agent conclude + // "nothing changed" and draft an empty description. Gather the full + // set of commits this branch adds over its base, plus any staged / + // unstaged work not yet committed. + let base = default_base_branch(&cwd).await; + let (branch_log, branch_diff) = branch_context(&cwd, &base).await; let staged_diff = run_git(&cwd, &["diff", "--staged", "--no-color"]).await?; let working_diff = run_git(&cwd, &["diff", "--no-color"]).await?; - let log = run_git(&cwd, &["log", "-n", "10", "--pretty=format:%s"]).await?; + + let has_any_content = [&branch_diff, &staged_diff, &working_diff] + .iter() + .any(|d| !d.trim().is_empty()) + || !branch_log.trim().is_empty(); + if !has_any_content { + return Err(format!( + "Nothing to describe — this branch has no commits beyond `{base}` and no \ + uncommitted changes. Commit your work first, then draft the PR." + )); + } let mut prompt = String::new(); if let Some(extras) = extras.as_deref() { @@ -107,12 +124,33 @@ pub async fn pr_draft( prompt.push_str("\n\n"); } } - prompt.push_str("Recent commit subjects:\n"); - prompt.push_str(&log); - prompt.push_str("\n\nStaged diff:\n"); - prompt.push_str(&truncate(&staged_diff, 4000)); - prompt.push_str("\n\nWorking-tree diff:\n"); - prompt.push_str(&truncate(&working_diff, 4000)); + prompt.push_str(&format!( + "You are describing a pull request that merges this branch into `{base}`. \ + Summarize ALL of the changes below as one cohesive PR.\n\n" + )); + if !branch_log.trim().is_empty() { + prompt.push_str(&format!( + "Commits on this branch (these all go into the PR):\n{}\n\n", + branch_log.trim() + )); + } + if !branch_diff.trim().is_empty() { + prompt.push_str(&format!( + "Full diff of this branch vs `{base}` (committed changes — the bulk of the PR):\n" + )); + prompt.push_str(&truncate(&branch_diff, 10000)); + prompt.push_str("\n\n"); + } + if !staged_diff.trim().is_empty() { + prompt.push_str("Staged but not-yet-committed diff:\n"); + prompt.push_str(&truncate(&staged_diff, 3000)); + prompt.push_str("\n\n"); + } + if !working_diff.trim().is_empty() { + prompt.push_str("Unstaged working-tree diff:\n"); + prompt.push_str(&truncate(&working_diff, 3000)); + prompt.push_str("\n\n"); + } let raw = run_inline(&cwd, &cli, HelperMode::PrDescription, &prompt, model.as_deref()).await?; @@ -498,6 +536,81 @@ async fn is_default_branch(cwd: &str, branch: &str) -> bool { branch == "main" || branch == "master" } +/// Resolve the branch a PR would target. Prefers `origin/HEAD` (the +/// remote's published default), falls back to a local `main`/`master`, +/// and finally to `"main"` so callers always get a usable name. +async fn default_base_branch(cwd: &str) -> String { + if let Ok(raw) = + run_git_checked(cwd, &["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).await + { + if let Some(default) = raw.trim().strip_prefix("origin/") { + if !default.is_empty() { + return default.to_string(); + } + } + } + for candidate in ["main", "master"] { + if run_git_checked(cwd, &["rev-parse", "--verify", &format!("refs/heads/{candidate}")]) + .await + .is_ok() + { + return candidate.to_string(); + } + } + "main".to_string() +} + +/// Gather the commits and cumulative diff this branch adds over `base` +/// — i.e. what the PR will actually contain, committed and pushed +/// included. Returns `(log, diff)` where `log` is the per-commit +/// subject+body list (oldest first) and `diff` is the full patch from +/// the merge-base to HEAD. +/// +/// Best-effort: returns empty strings when HEAD already equals the base +/// (nothing ahead) or when no merge-base can be found (unrelated +/// histories, a base ref that doesn't exist). The caller still has the +/// working-tree/staged diffs to fall back on in that case. +async fn branch_context(cwd: &str, base: &str) -> (String, String) { + // The current branch might BE the base (making a PR from an + // uncommitted change on main). Nothing is "ahead" of base then. + let current = run_git(cwd, &["symbolic-ref", "--short", "HEAD"]) + .await + .unwrap_or_default() + .trim() + .to_string(); + if !current.is_empty() && current == base { + return (String::new(), String::new()); + } + + // Prefer the remote-tracking base (what the PR merges into on the + // server); fall back to the local base ref when origin/ is + // absent (offline clone, never-fetched). + let merge_base = { + let origin_base = format!("origin/{base}"); + match run_git_checked(cwd, &["merge-base", &origin_base, "HEAD"]).await { + Ok(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => match run_git_checked(cwd, &["merge-base", base, "HEAD"]).await { + Ok(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => return (String::new(), String::new()), + }, + } + }; + + let range = format!("{merge_base}..HEAD"); + // `%s` subject, `%b` body, blank line between commits. Oldest first + // so the narrative reads in the order the work happened. + let log = run_git( + cwd, + &["log", "--reverse", "--pretty=format:- %s%n%b", &range], + ) + .await + .unwrap_or_default(); + let diff = run_git(cwd, &["diff", "--no-color", &format!("{merge_base}..HEAD")]) + .await + .unwrap_or_default(); + (log, diff) +} + /// Env vars that keep network-touching git from blocking on credential /// or passphrase prompts. Mirrors the rule in `git.rs::NON_INTERACTIVE_GIT_ENV` /// so the PR push behaves like the git panel's push. @@ -1359,6 +1472,68 @@ mod tests { "brand_new.txt should be tracked in HEAD; got ok={ok} stdout={stdout:?}"); } + // ---- branch_context / default_base_branch -------------------------- + + #[tokio::test] + async fn branch_context_sees_committed_and_pushed_work() { + // The regression: once work is committed AND pushed, the working + // tree is clean, so the old "git diff only" gather saw nothing. + // branch_context must still surface the commits + their diff. + let (clone, _bare) = build_repo_with_bare_remote("feature/shipped"); + std::fs::write(clone.path().join("feature.txt"), "new feature\n").unwrap(); + run_sync(clone.path(), &["add", "feature.txt"]); + run_sync(clone.path(), &["commit", "-m", "Add the feature"]); + run_sync(clone.path(), &["push", "-u", "origin", "feature/shipped"]); + + // Clean tree — nothing dirty, everything pushed. + let porcelain = run_sync(clone.path(), &["status", "--porcelain"]); + assert!(porcelain.trim().is_empty(), "precondition: clean tree"); + + let cwd = clone.path().to_str().unwrap(); + let base = default_base_branch(cwd).await; + assert_eq!(base, "main"); + let (log, diff) = branch_context(cwd, &base).await; + assert!(log.contains("Add the feature"), "commit subject missing: {log:?}"); + assert!(diff.contains("new feature"), "committed diff missing: {diff:?}"); + assert!(diff.contains("feature.txt"), "changed file missing: {diff:?}"); + } + + #[tokio::test] + async fn branch_context_empty_when_on_base_branch() { + // A PR-from-main scenario: HEAD == base, nothing is "ahead", so + // there's no branch diff (the caller falls back to working-tree). + let (clone, _bare) = build_repo_with_bare_remote("feature/unused"); + run_sync(clone.path(), &["checkout", "main"]); + + let cwd = clone.path().to_str().unwrap(); + let base = default_base_branch(cwd).await; + assert_eq!(base, "main"); + let (log, diff) = branch_context(cwd, &base).await; + assert!(log.trim().is_empty(), "no commits should be ahead of base: {log:?}"); + assert!(diff.trim().is_empty(), "no diff should be ahead of base: {diff:?}"); + } + + #[tokio::test] + async fn branch_context_multiple_commits_all_included() { + // "everything I pushed should all go into one PR" — every commit + // on the branch beyond base must appear, not just the latest. + let (clone, _bare) = build_repo_with_bare_remote("feature/multi"); + for (name, msg) in [("a.txt", "first commit"), ("b.txt", "second commit")] { + std::fs::write(clone.path().join(name), "x\n").unwrap(); + run_sync(clone.path(), &["add", name]); + run_sync(clone.path(), &["commit", "-m", msg]); + } + run_sync(clone.path(), &["push", "-u", "origin", "feature/multi"]); + + let cwd = clone.path().to_str().unwrap(); + let base = default_base_branch(cwd).await; + let (log, diff) = branch_context(cwd, &base).await; + assert!(log.contains("first commit"), "first commit missing: {log:?}"); + assert!(log.contains("second commit"), "second commit missing: {log:?}"); + assert!(diff.contains("a.txt") && diff.contains("b.txt"), + "both files should be in the cumulative diff: {diff:?}"); + } + // ---- ssh_url_to_https ---------------------------------------------- #[test] diff --git a/src-tauri/src/term.rs b/src-tauri/src/term.rs index 4cfb4f9..238b5bc 100644 --- a/src-tauri/src/term.rs +++ b/src-tauri/src/term.rs @@ -55,6 +55,16 @@ use tauri::{AppHandle, Emitter, Manager, State, Wry}; /// session will ever reach. alacritty initializes scrollback rows /// dynamically, so a sky-high cap doesn't pre-allocate memory — rows /// are only stored when the user actually scrolls into them. +/// +/// CAUTION — this cannot be capped without reworking scrollback +/// mirroring: `maybe_flush` derives the rows-scrolled-out delta from +/// `grid().history_size()` growth. A finite cap saturates that +/// counter (evicting the oldest row keeps the size constant), the +/// delta reads as 0, and the frontend mirror silently stops receiving +/// scrollback while output keeps flowing. Bounding resident memory +/// per pane is instead handled where it's safe: the JS mirror drops +/// its oldest rows past a cap, and the FlatTerm scaffold (the planned +/// alacritty replacement) carries its own eviction design. const SCROLLBACK_LIMIT: usize = usize::MAX / 2; /// `alacritty_terminal::term::Config` with our unbounded scrollback @@ -69,22 +79,27 @@ fn term_config() -> TermConfig { } } /// Frame throttle while the session is visible to the user AND the -/// Goonware window has focus. 8 ms ≈ one frame at 120 Hz, matching the -/// MacBook Pro / Pro Display XDR ProMotion refresh rate. On non- -/// ProMotion 60 Hz displays the compositor coalesces back to 60 fps -/// automatically, so the higher cap is free for those users — -/// they get the same 60 fps perception with marginally more headroom -/// for sudden burst output to land in fewer coalesced frames. -const FRAME_THROTTLE_VISIBLE: Duration = Duration::from_millis(8); +/// Goonware window has focus. 16 ms ≈ one frame at 60 Hz. Every flush +/// pays a full `snapshot_grid` walk + row diff + serde IPC per visible +/// pane, so the previous 8 ms (125 Hz, aimed at ProMotion) doubled all +/// of that for zero perceptible gain on streaming text — terminal +/// output is not an animation the eye tracks between 60 and 120 Hz, +/// and with several visible panes the extra flushes were pure heat. +const FRAME_THROTTLE_VISIBLE: Duration = Duration::from_millis(16); /// Frame throttle while the session is currently NOT shown anywhere -/// in the UI but the Goonware window is otherwise focused. Kept close to -/// the visible cadence (32 ms ≈ 30 Hz) so that a worktree-switch -/// race between the user starting to type and `term_set_visible_set` -/// landing on the backend doesn't introduce a perceptible delay -/// before the freshly-active terminal starts echoing keystrokes. -/// The previous 250 ms value visibly stalled the first 1–2 frames -/// after every switch. -const FRAME_THROTTLE_HIDDEN: Duration = Duration::from_millis(32); +/// in the UI but the Goonware window is otherwise focused. Hidden +/// panes only need frames at all so the JS scrollback mirror and +/// block segmentation stay warm — nobody sees the paints. 100 ms +/// keeps 20 hidden streaming agents down to ~200 flushes/sec total +/// (vs ~600 at the old 32 ms) while staying comfortably under the +/// perception threshold for the one race this cadence protects: +/// keystrokes echoing into a freshly-activated terminal before +/// `term_set_visible_set` lands on the backend. (A 250 ms value was +/// tried historically and visibly stalled that first echo; 32 ms was +/// the overcorrection.) Visibility transitions also force an +/// immediate catch-up flush in `term_set_visible_set`, so switch +/// latency does not depend on this constant. +const FRAME_THROTTLE_HIDDEN: Duration = Duration::from_millis(100); /// Frame throttle while the Goonware window is BACKGROUNDED (user is on /// another app). The webview's JS context is suspended by macOS, so /// every event we emit just queues in V8's message buffer until the @@ -151,9 +166,9 @@ pub fn flush_all_sessions(app: &AppHandle, state: &TerminalState) { /// Called from the frontend whenever the active worktree, active tab, /// or secondary terminal selection changes. The set is small — usually /// 1 to 2 PTYs — but the impact is large: every session NOT in the -/// set drops to `FRAME_THROTTLE_HIDDEN` (4 Hz), so 20 streaming agents -/// with only 1 visible at a time generates ~120 events/sec total -/// instead of the previous ~1200. +/// set drops to `FRAME_THROTTLE_HIDDEN` (10 Hz), so 20 streaming agents +/// with only 1 visible at a time generate ~260 events/sec total +/// instead of the ~2500 an unthrottled set would produce. /// /// Transitions: any session that just became visible immediately /// gets one catch-up frame so the user sees current state on switch, diff --git a/src-tauri/src/warp_term.rs b/src-tauri/src/warp_term.rs index 30ba271..29a8798 100644 --- a/src-tauri/src/warp_term.rs +++ b/src-tauri/src/warp_term.rs @@ -82,6 +82,31 @@ const BLOCK_PAD_X: f32 = 14.0; const BLOCK_PAD_Y: f32 = 8.0; /// Width (px) of the red left stripe on a failed block. const STRIPE_W: f32 = 2.0; +/// How many of the newest closed blocks the scroll-back can page through. +/// Matches the persistence restore window (`HISTORY_LOAD_WINDOW = 500`), so the +/// user can scroll through their entire restored history. This is NO LONGER a +/// performance bound — transcript virtualization makes each frame O(viewport) +/// regardless of depth, and each block's height is cached (`NativeBlock.height`) +/// so the per-frame height sweep is O(blocks), not O(rows). Older history beyond +/// this stays on disk and would need a deeper load window to surface. +const BLOCK_RENDER_CAP: usize = 500; +/// Storage cap for `TermGrid::blocks`. Everything past the render cap +/// is unpaintable (render always windows to the newest +/// `BLOCK_RENDER_CAP`), so retaining more in memory only duplicated +/// what SQLite already persists — each NativeBlock holds a full +/// transcript String + row snapshots, which added up fast across +/// long sessions. Evicting the front keeps the rendered window +/// byte-identical. +const BLOCK_STORE_CAP: usize = BLOCK_RENDER_CAP; +/// Max rows retained in `TermGrid::scrollback` for the live +/// (in-progress) block. 10k rows is far more than a user will ever +/// scrub through mid-command; a finished block re-renders from its +/// own transcript, so nothing is lost at block close. +const LIVE_SCROLLBACK_CAP: usize = 10_000; +/// Eviction hysteresis: only drain once we're this many rows past the +/// cap so the O(n) front-drain amortizes instead of running per row. +const SCROLLBACK_EVICT_CHUNK: usize = 1024; + /* ------------------------------------------------------------------ Assets — warpui loads fonts from the OS, so the embedded surface needs no bundled assets. Surface a clear error if it asks for one. @@ -286,6 +311,15 @@ impl TermGrid { spans: d.spans.clone(), }); } + // Bound the live block's scrolled-off mirror. A chatty agent + // that streams for hours would otherwise grow this without + // limit — and it's the THIRD copy of that output (alacritty + // grid + JS mirror hold the others). Evict oldest in chunks + // so the O(n) drain amortizes to ~zero per appended row. + if self.scrollback.len() > LIVE_SCROLLBACK_CAP + SCROLLBACK_EVICT_CHUNK { + let excess = self.scrollback.len() - LIVE_SCROLLBACK_CAP; + self.scrollback.drain(..excess); + } } self.frames = self.frames.wrapping_add(1); @@ -1868,6 +1902,53 @@ fn load_mono(cx: &mut ViewContext) -> FamilyId { Attach + commands. ------------------------------------------------------------------ */ +/// True while a redraw poke is queued for the main thread but hasn't +/// run yet. The frame/block sinks run on PTY reader threads — with N +/// streaming panes each flushing up to 60 Hz, dispatching one GCD +/// main-thread hop per frame produced hundreds of queued closures per +/// second that all collapsed into the same `setNeedsDisplay`. One +/// pending poke is enough: AppKit coalesces the actual draw anyway, +/// and any frame applied before the poke runs is picked up by that +/// same display pass. +static REDRAW_POKE_PENDING: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// Coalesced `poke_embedded_redraw`: skips the main-thread dispatch +/// entirely when one is already queued. Safe ordering: the flag is +/// cleared on the main thread BEFORE the poke, so a frame that lands +/// after the clear either sees pending=false and queues a fresh poke, +/// or was already applied and is covered by the in-flight one. +fn schedule_embedded_redraw(app: &tauri::AppHandle) { + use std::sync::atomic::Ordering; + if REDRAW_POKE_PENDING.swap(true, Ordering::AcqRel) { + return; + } + let dispatched = app.run_on_main_thread(|| { + REDRAW_POKE_PENDING.store(false, Ordering::Release); + warpui::platform::poke_embedded_redraw(); + }); + // If the dispatch itself failed (event loop tearing down), clear + // the flag ourselves — otherwise every future poke is silently + // swallowed and the surface freezes for the rest of the session. + if dispatched.is_err() { + REDRAW_POKE_PENDING.store(false, Ordering::Release); + } +} + +/// Evict the front of a grid's block list once it exceeds `BLOCK_STORE_CAP`. +/// Render only ever windows to the newest `BLOCK_RENDER_CAP` blocks, so +/// anything older is dead weight in memory (SQLite keeps the full history +/// on disk). Dropping the front keeps per-pty memory flat across long +/// sessions — the point of the energy-efficiency pass. The +/// `blocks_by_pty` index and the newest-N window mean nothing paintable +/// is ever discarded. +fn cap_block_store(blocks: &mut Vec) { + if blocks.len() > BLOCK_STORE_CAP { + let excess = blocks.len() - BLOCK_STORE_CAP; + blocks.drain(..excess); + } +} + /// Stand up the embedded warpui surface and wire the in-process frame path. /// Call once from the Tauri `.setup()` on the main thread. pub fn attach(app: &tauri::AppHandle) { @@ -1919,10 +2000,11 @@ pub fn attach(app: &tauri::AppHandle) { false }; drop(routing); + // Only a visible pane needs the surface repainted; hidden PTYs just + // warmed their retained model. Coalesced so a burst of frames queues + // at most one main-thread poke. if visible { - let _ = app_for_sink.run_on_main_thread(|| { - warpui::platform::poke_embedded_redraw(); - }); + schedule_embedded_redraw(&app_for_sink); } use std::sync::atomic::{AtomicU32, Ordering}; static N: AtomicU32 = AtomicU32::new(0); @@ -1963,6 +2045,7 @@ pub fn attach(app: &tauri::AppHandle) { block.duration_ms, block.exit_code, )); + cap_block_store(&mut g.blocks); true } else { let mut cache = hidden_grids().lock().unwrap_or_else(|e| e.into_inner()); @@ -1979,14 +2062,15 @@ pub fn attach(app: &tauri::AppHandle) { block.duration_ms, block.exit_code, )); + cap_block_store(&mut g.blocks); } false }; drop(routing); + // Coalesced, and only when the pane is on-screen — a hidden pane's + // blocks live in the retained model until the user switches to it. if visible { - let _ = app_for_block.run_on_main_thread(|| { - warpui::platform::poke_embedded_redraw(); - }); + schedule_embedded_redraw(&app_for_block); } })); diff --git a/src/hooks/useGitStatus.ts b/src/hooks/useGitStatus.ts index f59af8f..08d84d8 100644 --- a/src/hooks/useGitStatus.ts +++ b/src/hooks/useGitStatus.ts @@ -1,56 +1,35 @@ -import { useEffect, useState } from "react"; -import { git, type StatusEntry } from "../lib/git"; +import { useMemo } from "react"; +import { type StatusEntry } from "../lib/git"; +import { useSharedGitStatus } from "../state/gitStatusStore"; export type GitStatusMap = Map; +const EMPTY_MAP: GitStatusMap = new Map(); + /** - * Polls `git status` for the given project root and returns a path → - * status entry map. Path keys are absolute (joined with the project - * root) so the file tree can do an O(1) lookup per row. + * `git status` for the given project root as a path → status entry + * map. Path keys are absolute (joined with the project root) so the + * file tree can do an O(1) lookup per row. * - * Polls at a relaxed cadence — git status reads are fast but not free, - * and the file tree doesn't need sub-second freshness. + * Backed by the shared per-cwd git-status store, so the file tree and + * every terminal status bar polling the same repo share one 4s poll + * (paused while the window is hidden) instead of each running their + * own subprocess-spawning interval. */ export function useGitStatus(projectPath: string | null): GitStatusMap { - const [map, setMap] = useState(() => new Map()); - - useEffect(() => { - if (!projectPath) { - setMap(new Map()); - return; - } - - let cancelled = false; + const status = useSharedGitStatus(projectPath); + return useMemo(() => { + if (!projectPath || !status) return EMPTY_MAP; const root = projectPath.replace(/\/$/, ""); - - const refresh = async () => { - try { - const status = await git.status(projectPath); - if (cancelled) return; - const next: GitStatusMap = new Map(); - for (const e of status.entries) { - // Git emits paths relative to the repo root. Normalize to - // the absolute paths the file tree uses. - const abs = `${root}/${e.path}`; - next.set(abs, e); - } - setMap(next); - } catch { - // Project might not be a git repo — leave the map empty - // rather than spamming errors. - if (!cancelled) setMap(new Map()); - } - }; - - void refresh(); - const id = window.setInterval(refresh, 4000); - return () => { - cancelled = true; - window.clearInterval(id); - }; - }, [projectPath]); - - return map; + const next: GitStatusMap = new Map(); + for (const e of status.entries) { + // Git emits paths relative to the repo root. Normalize to + // the absolute paths the file tree uses. + const abs = `${root}/${e.path}`; + next.set(abs, e); + } + return next; + }, [projectPath, status]); } /* ------------------------------------------------------------------ diff --git a/src/lib/claudeUsage.ts b/src/lib/claudeUsage.ts index 4b9d77c..2fd0376 100644 --- a/src/lib/claudeUsage.ts +++ b/src/lib/claudeUsage.ts @@ -14,7 +14,7 @@ * because the banner just appeared"). That's a separate concern from * usage budgeting. */ -import { useEffect, useState, useSyncExternalStore } from "react"; +import { useSyncExternalStore } from "react"; import { invoke } from "@tauri-apps/api/core"; /** Anthropic's published rolling-window length. Kept here only for @@ -203,6 +203,62 @@ function getStoreStatus(): ClaudeUsageStatus | null { return storeStatus; } +// ── Shared 1Hz clock ───────────────────────────────────────────── +// One interval for ALL pills. Each ClaudeUsagePillInner used to run +// its own setInterval(…, 1000) — N agent panes meant N wakeups + N +// re-renders per second, including for panes hidden behind other +// tabs. The shared clock ticks once, only while at least one pill is +// mounted, and pauses while the document is hidden (the label snaps +// to the correct value on the first tick after re-show since it's +// derived from Date.now()). +let sharedNow = Date.now(); +let nowTimer: number | null = null; +let nowVisibilityHooked = false; +const nowListeners = new Set<() => void>(); + +function startNowTicker() { + if (nowTimer !== null || document.hidden || nowListeners.size === 0) return; + // Snap the clock forward on (re)start — it's frozen while no pill + // is mounted, and a stale value would render a wrong remaining-time + // label for up to a second on first mount. + sharedNow = Date.now(); + nowTimer = window.setInterval(() => { + sharedNow = Date.now(); + nowListeners.forEach((fn) => fn()); + }, 1000); +} + +function stopNowTicker() { + if (nowTimer === null) return; + window.clearInterval(nowTimer); + nowTimer = null; +} + +function subscribeNow(notify: () => void): () => void { + if (!nowVisibilityHooked) { + nowVisibilityHooked = true; + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stopNowTicker(); + } else { + sharedNow = Date.now(); + nowListeners.forEach((fn) => fn()); + startNowTicker(); + } + }); + } + nowListeners.add(notify); + startNowTicker(); + return () => { + nowListeners.delete(notify); + if (nowListeners.size === 0) stopNowTicker(); + }; +} + +function getSharedNow(): number { + return sharedNow; +} + /** * Subscribes to the singleton polling store. `status` only changes * when the underlying Tauri response changes; consumers get the @@ -220,11 +276,7 @@ export function useClaudeUsage(): { getStoreStatus, getStoreStatus, ); - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - const id = window.setInterval(() => setNow(Date.now()), 1000); - return () => window.clearInterval(id); - }, []); + const now = useSyncExternalStore(subscribeNow, getSharedNow, getSharedNow); return { status, derived: status ? deriveStatus(status, now) : null, diff --git a/src/lib/urlMatch.ts b/src/lib/urlMatch.ts index 4c7e5a1..978c2db 100644 --- a/src/lib/urlMatch.ts +++ b/src/lib/urlMatch.ts @@ -46,6 +46,14 @@ function normalize(raw: string): string { export function splitUrls(input: string): UrlFragment[] { if (!input) return []; + // Fast path for the overwhelmingly common case: this runs per span + // per changed row in the terminal render path (up to 60 Hz on a + // streaming pane), and almost no spans contain a URL. Both accepted + // shapes require "http" or "localhost:", so one indexOf pair skips + // the matchAll + iterator allocation entirely for plain text. + if (!input.includes("http") && !input.includes("localhost:")) { + return [{ kind: "text", text: input }]; + } const out: UrlFragment[] = []; let cursor = 0; for (const match of input.matchAll(URL_RE)) { diff --git a/src/shell/MainColumn.tsx b/src/shell/MainColumn.tsx index 2962cd9..53d09d3 100644 --- a/src/shell/MainColumn.tsx +++ b/src/shell/MainColumn.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { AnimatePresence, motion } from "motion/react"; import { IconPlus, @@ -21,7 +21,10 @@ import { forgetPtys } from "@/terminal/sessionMemory"; import { useToast } from "@/primitives/Toast"; import { ArrowsOutSimpleIcon, FolderDashedIcon } from "@phosphor-icons/react"; import { ErrorBoundary } from "./ErrorBoundary"; -import { BlockTerminal } from "@/terminal/BlockTerminal"; +import { + BlockTerminal, + type DetectedAgentCli, +} from "@/terminal/BlockTerminal"; import { WarpSurfaceTracker } from "@/terminal/WarpSurfaceTracker"; import { DiffView } from "@/git/DiffView"; import { AllChangesView } from "@/git/AllChangesView"; @@ -1189,6 +1192,89 @@ function TerminalTabContent({ }) { const dispatch = useAppDispatch(); const { settings } = useAppState(); + // Latest-value refs so the callbacks below can stay referentially + // stable (empty useCallback deps) while still reading current tab / + // worktree / settings state. BlockTerminal is memo'd — a fresh + // callback identity per render would defeat the memo and re-run the + // whole 2,500-line pane body on every app-state dispatch, once per + // mounted pane. + const tabRef = useRef(tab); + tabRef.current = tab; + const worktreeRef = useRef(worktree); + worktreeRef.current = worktree; + const settingsRef = useRef(settings); + settingsRef.current = settings; + + const onAgentRunningChange = useCallback( + (running: boolean, cli: DetectedAgentCli) => { + const tab = tabRef.current; + const worktree = worktreeRef.current; + const settings = settingsRef.current; + dispatch({ + type: "update-tab", + id: tab.id, + patch: { + agentStatus: running ? "running" : "idle", + detectedCli: cli ?? null, + }, + }); + dispatch({ + type: "set-agent-status", + worktreeId: worktree.id, + status: running ? "running" : "idle", + cli: cli ?? worktree.agentCli, + }); + // Settings-driven side effects on running→idle transition. + if (!running && tab.agentStatus === "running") { + if (settings.notifyOnIdle) { + void notifyAgentFinished(worktree.name, tab.title); + } + if (settings.completionSound !== "none") { + playCompletionSound(settings.completionSound); + } + } + }, + [dispatch], + ); + + const onActivitySummaryChange = useCallback( + (summary: string) => { + if (!summary) return; + const tab = tabRef.current; + dispatch({ type: "set-tab-summary", id: tab.id, summary }); + const isPlaceholder = + tab.title === "Untitled" || tab.title === "main" || tab.title === ""; + if (isPlaceholder) { + const derived = summary + .replace(/\s+/g, " ") + .trim() + .split(" ") + .slice(0, 5) + .join(" ") + .slice(0, 40); + // Skip the bare-launch-command case: when the activity + // source is just "claude" / "codex" / "gemini" (the user + // typed the agent's name and the AI summarizer hasn't + // produced a real activity line yet), promoting that into + // tab.title pollutes the title with the agent's name. The + // tab strip already shows the CLI badge via tabLabel while + // the agent runs, so we don't need it duplicated in the + // underlying title — and once the agent exits we'd be + // stuck with "claude" as the persistent title forever. + const looksLikeBareCli = + /^(claude(-code)?|codex(-cli)?|gemini(-cli)?|aider)$/i.test(derived); + if (derived && !looksLikeBareCli) { + dispatch({ + type: "update-tab", + id: tab.id, + patch: { title: derived }, + }); + } + } + }, + [dispatch], + ); + return ( { - dispatch({ - type: "update-tab", - id: tab.id, - patch: { - agentStatus: running ? "running" : "idle", - detectedCli: cli ?? null, - }, - }); - dispatch({ - type: "set-agent-status", - worktreeId: worktree.id, - status: running ? "running" : "idle", - cli: cli ?? worktree.agentCli, - }); - // Settings-driven side effects on running→idle transition. - if (!running && tab.agentStatus === "running") { - if (settings.notifyOnIdle) { - void notifyAgentFinished(worktree.name, tab.title); - } - if (settings.completionSound !== "none") { - playCompletionSound(settings.completionSound); - } - } - }} - onActivitySummaryChange={(summary) => { - if (!summary) return; - dispatch({ type: "set-tab-summary", id: tab.id, summary }); - const isPlaceholder = - tab.title === "Untitled" || tab.title === "main" || tab.title === ""; - if (isPlaceholder) { - const derived = summary - .replace(/\s+/g, " ") - .trim() - .split(" ") - .slice(0, 5) - .join(" ") - .slice(0, 40); - // Skip the bare-launch-command case: when the activity - // source is just "claude" / "codex" / "gemini" (the user - // typed the agent's name and the AI summarizer hasn't - // produced a real activity line yet), promoting that into - // tab.title pollutes the title with the agent's name. The - // tab strip already shows the CLI badge via tabLabel while - // the agent runs, so we don't need it duplicated in the - // underlying title — and once the agent exits we'd be - // stuck with "claude" as the persistent title forever. - const looksLikeBareCli = - /^(claude(-code)?|codex(-cli)?|gemini(-cli)?|aider)$/i.test(derived); - if (derived && !looksLikeBareCli) { - dispatch({ - type: "update-tab", - id: tab.id, - patch: { title: derived }, - }); - } - } - }} + onAgentRunningChange={onAgentRunningChange} + onActivitySummaryChange={onActivitySummaryChange} /> ); } diff --git a/src/shell/RightPanel.tsx b/src/shell/RightPanel.tsx index 73b3555..7df6b81 100644 --- a/src/shell/RightPanel.tsx +++ b/src/shell/RightPanel.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type RefObject } from "react"; import { AnimatePresence, motion } from "motion/react"; import { invoke } from "@tauri-apps/api/core"; import { @@ -576,8 +576,16 @@ function useWorktreeStatus( let cancelled = false; const tick = async () => { if (cancelled) return; + // Skip the subprocess-spawning git status while the window is + // hidden; the visibilitychange listener below reconciles + // immediately when the user comes back. + if (document.hidden) return; await refresh(); }; + const onVisible = () => { + if (!document.hidden) void tick(); + }; + document.addEventListener("visibilitychange", onVisible); // Seed from the last-known status for this worktree so a switch // paints real data immediately (refreshed in the background by // the tick below) instead of flashing an empty pane. Worktrees @@ -601,6 +609,7 @@ function useWorktreeStatus( cancelled = true; window.clearInterval(t); window.removeEventListener("goonware-git-refresh", onRefresh); + document.removeEventListener("visibilitychange", onVisible); }; }, [worktreeId, worktreePath, refresh, skip]); @@ -633,6 +642,13 @@ function ChangesView({ const toast = useToast(); const [message, setMessage] = useState(""); const [busy, setBusy] = useState(null); + // The commit textarea. We focus it explicitly after an AI draft lands + // — the helper spawns a CLI in the worktree, and the terminal can + // reclaim focus while it runs, so without this the drafted text sits + // in a box the user isn't typing into (their next keystrokes go to + // the terminal instead). Focusing here guarantees the message lands + // in — and stays editable in — the commit box. + const composerRef = useRef(null); const stagedCount = useMemo( () => entries.filter((e) => e.staged).length, @@ -695,7 +711,18 @@ function ChangesView({ model, extras || undefined, ); - setMessage(text.trim()); + const drafted = text.trim(); + setMessage(drafted); + // Land focus in the commit box with the caret at the end so the + // user can immediately edit and then Tab/click away. Deferred a + // frame so it wins any focus the finishing helper CLI grabbed. + requestAnimationFrame(() => { + const ta = composerRef.current; + if (!ta) return; + ta.focus(); + const end = drafted.length; + ta.setSelectionRange(end, end); + }); } catch (e) { toast.show({ message: `AI draft failed: ${e}` }); } finally { @@ -860,6 +887,7 @@ function ChangesView({ >
; message: string; onChange: (s: string) => void; onDraft: () => void; @@ -1236,6 +1266,7 @@ function CommitComposer({ }} >