From fc73d14534ec246138fe66b5218f1ca2766d1040 Mon Sep 17 00:00:00 2001 From: raeedz Date: Thu, 23 Jul 2026 17:57:50 -0700 Subject: [PATCH 1/2] Reduce terminal memory/CPU overhead across Rust and JS layers - Cap FlatTerm scrollback and on-disk block history instead of growing unbounded - Lower visible-pane frame throttle from 8ms to 16ms to cut redundant flush work - Add gitStatusStore and rework claudeUsage/status polling to avoid redundant work --- src-tauri/src/flat_term.rs | 20 +++- src-tauri/src/persistence/mod.rs | 17 +-- src-tauri/src/persistence/writer.rs | 24 +++- src-tauri/src/term.rs | 51 +++++--- src-tauri/src/warp_term.rs | 73 +++++++++++- src/hooks/useGitStatus.ts | 69 ++++------- src/lib/claudeUsage.ts | 64 +++++++++- src/lib/urlMatch.ts | 8 ++ src/shell/MainColumn.tsx | 150 ++++++++++++++---------- src/shell/RightPanel.tsx | 9 ++ src/state/gitStatusStore.ts | 162 ++++++++++++++++++++++++++ src/terminal/BlockTerminal.tsx | 39 ++++++- src/terminal/LiveBlock.tsx | 45 +++---- src/terminal/TerminalStatusBar.tsx | 102 +++++----------- src/terminal/WarpSurfaceTracker.tsx | 34 +++++- src/terminal/sessionMemory.ts | 31 ++++- src/terminal/terminalActivityStore.ts | 20 ++++ src/terminal/useTerminalSession.ts | 50 ++++++-- 18 files changed, 705 insertions(+), 263 deletions(-) create mode 100644 src/state/gitStatusStore.ts 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 24897c3..e5c9c8b 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` windows the -//! most-recent rows for fast restore; older blocks page in on -//! scroll-back. +//! - **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 @@ -116,9 +117,9 @@ pub struct SavedBlock { } /// How many of the most-recent blocks `load_blocks` returns on restore. -/// History is retained in full on disk (Warp-parity — never cut off); -/// the renderer pages in older blocks on scroll-back. Kept in sync with -/// the front-end's `MAX_BLOCKS` in `sessionMemory.ts`. +/// 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 diff --git a/src-tauri/src/persistence/writer.rs b/src-tauri/src/persistence/writer.rs index 7a6bdd1..1586fe9 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 { @@ -278,9 +284,21 @@ fn apply( now, ], )?; - // Warp-parity: never evict by count. Full block history is - // retained on disk so it's never cut off; `load_blocks` - // windows the most-recent rows for fast restore. + // 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/term.rs b/src-tauri/src/term.rs index 512bc5c..f21cc15 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 dd615c7..cf9a6ab 100644 --- a/src-tauri/src/warp_term.rs +++ b/src-tauri/src/warp_term.rs @@ -89,6 +89,22 @@ const STRIPE_W: f32 = 2.0; /// 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 @@ -268,6 +284,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); @@ -1701,6 +1726,39 @@ 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); + } +} + /// 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) { @@ -1736,9 +1794,7 @@ pub fn attach(app: &tauri::AppHandle) { } } } - 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); let n = N.fetch_add(1, Ordering::Relaxed); @@ -1773,10 +1829,15 @@ pub fn attach(app: &tauri::AppHandle) { block.duration_ms, block.exit_code, )); + // Render only ever windows to the newest BLOCK_RENDER_CAP + // blocks, so anything older is dead weight (SQLite keeps + // the full history). Drop the front to keep memory flat. + if g.blocks.len() > BLOCK_STORE_CAP { + let excess = g.blocks.len() - BLOCK_STORE_CAP; + g.blocks.drain(..excess); + } } - let _ = app_for_block.run_on_main_thread(|| { - warpui::platform::poke_embedded_redraw(); - }); + schedule_embedded_redraw(&app_for_block); })); let parent = app 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 bfcb900..9180822 100644 --- a/src/lib/urlMatch.ts +++ b/src/lib/urlMatch.ts @@ -41,6 +41,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..00d893f 100644 --- a/src/shell/RightPanel.tsx +++ b/src/shell/RightPanel.tsx @@ -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]); diff --git a/src/state/gitStatusStore.ts b/src/state/gitStatusStore.ts new file mode 100644 index 0000000..b8bd04d --- /dev/null +++ b/src/state/gitStatusStore.ts @@ -0,0 +1,162 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { git, type StatusResult } from "../lib/git"; + +/** + * Shared per-cwd `git status` poller. + * + * Before this store existed, every mounted TerminalStatusBar (one per + * kept-alive terminal pane, including hidden ones) plus the file tree + * ran its own 4s `git_status` interval against the same repo. With N + * agent panes in a worktree that meant N+ identical `git status` + * subprocess spawns every 4 seconds, forever, even with the window + * backgrounded — pure battery burn. + * + * This store dedupes to exactly ONE poll per distinct cwd, refcounted + * by subscriber count, and pauses entirely while the document is + * hidden (with an immediate reconcile when it becomes visible again). + * `goonware-git-refresh` nudges (commit/push/merge) refresh the + * matching cwd immediately, same contract as the old per-component + * listeners. + */ + +const POLL_MS = 4000; + +interface Entry { + refs: number; + status: StatusResult | null; + timer: number | null; + listeners: Set<() => void>; + inFlight: boolean; + /** A pull was requested while one was in flight (e.g. a + * goonware-git-refresh nudge racing the 4s poll) — run one more + * when the current pull settles instead of dropping the nudge. */ + queued: boolean; +} + +const entries = new Map(); +let globalHooksInstalled = false; + +function installGlobalHooks() { + if (globalHooksInstalled) return; + globalHooksInstalled = true; + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + for (const e of entries.values()) stopTimer(e); + } else { + for (const [cwd, e] of entries) { + if (e.refs > 0) { + void pull(cwd, e); + startTimer(cwd, e); + } + } + } + }); + window.addEventListener("goonware-git-refresh", (ev: Event) => { + const detail = (ev as CustomEvent<{ cwd?: string }>).detail; + for (const [cwd, e] of entries) { + if (e.refs > 0 && (!detail?.cwd || detail.cwd === cwd)) { + void pull(cwd, e); + } + } + }); +} + +async function pull(cwd: string, e: Entry) { + if (e.inFlight) { + e.queued = true; + return; + } + e.inFlight = true; + try { + const status = await git.status(cwd); + e.status = status; + } catch { + // Not a git repo / transient failure — expose null so consumers + // can fall back to their empty state. + e.status = null; + } finally { + e.inFlight = false; + } + e.listeners.forEach((fn) => fn()); + if (e.queued && e.refs > 0) { + e.queued = false; + void pull(cwd, e); + } +} + +function startTimer(cwd: string, e: Entry) { + if (e.timer !== null) return; + e.timer = window.setInterval(() => void pull(cwd, e), POLL_MS); +} + +function stopTimer(e: Entry) { + if (e.timer === null) return; + window.clearInterval(e.timer); + e.timer = null; +} + +export function subscribeGitStatus(cwd: string, notify: () => void): () => void { + installGlobalHooks(); + let e = entries.get(cwd); + if (!e) { + e = { + refs: 0, + status: null, + timer: null, + listeners: new Set(), + inFlight: false, + queued: false, + }; + entries.set(cwd, e); + } + e.refs += 1; + e.listeners.add(notify); + if (e.refs === 1) { + void pull(cwd, e); + if (!document.hidden) startTimer(cwd, e); + } + return () => { + e.refs -= 1; + e.listeners.delete(notify); + if (e.refs <= 0) { + stopTimer(e); + // Drop the entry entirely so long-gone cwds don't accumulate + // stale StatusResults for the app's lifetime. + entries.delete(cwd); + } + }; +} + +export function getGitStatusSnapshot(cwd: string): StatusResult | null { + return entries.get(cwd)?.status ?? null; +} + +/** Force an immediate re-poll for a cwd (used by explicit refresh buttons). */ +export function refreshGitStatus(cwd: string): void { + const e = entries.get(cwd); + if (e && e.refs > 0) void pull(cwd, e); +} + +/** + * React hook: latest StatusResult for `cwd`, shared across all + * subscribers of the same cwd. Returns null while unknown / not a repo. + * + * The subscribe/getSnapshot callbacks MUST be memoized on `cwd`: + * useSyncExternalStore re-subscribes whenever the subscribe identity + * changes, and our unsubscribe has real side effects (refcount drop → + * entry delete → cache loss → fresh `git status` subprocess on + * resubscribe). An inline closure here turned every consumer render + * into a poller teardown + subprocess spawn — the exact storm this + * store exists to prevent. + */ +export function useSharedGitStatus(cwd: string | null): StatusResult | null { + const subscribe = useCallback( + (notify: () => void) => (cwd ? subscribeGitStatus(cwd, notify) : () => {}), + [cwd], + ); + const getSnapshot = useCallback( + () => (cwd ? getGitStatusSnapshot(cwd) : null), + [cwd], + ); + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} diff --git a/src/terminal/BlockTerminal.tsx b/src/terminal/BlockTerminal.tsx index 7689e11..213ca56 100644 --- a/src/terminal/BlockTerminal.tsx +++ b/src/terminal/BlockTerminal.tsx @@ -1,4 +1,5 @@ import { + memo, useCallback, useEffect, useLayoutEffect, @@ -17,7 +18,11 @@ import { PromptInput, type PromptInputHandle } from "./PromptInput"; import { PtyPassthrough, type PtyPassthroughHandle } from "./PtyPassthrough"; import { TerminalStatusBar } from "./TerminalStatusBar"; import { useTerminalSession } from "./useTerminalSession"; -import { getHistory, setHistory as memSetHistory } from "./sessionMemory"; +import { + getHistory, + setHistory as memSetHistory, + MAX_HISTORY, +} from "./sessionMemory"; import { getLastInteractedTerminal, markTerminalInteracted, @@ -191,8 +196,15 @@ const BELL_FLASH_MS = 480; * When the running shell pushes alt-screen (vim/htop/claude TUI), we * swap the BlockList + PromptInput stack for a FullGrid that mirrors * the entire grid and forwards every keystroke. + * + * Memoized (see the `BlockTerminal` export at the bottom of this + * file): the keepalive layer keeps every terminal tab of the 3 MRU + * worktrees mounted, so without memo every app-state dispatch re-ran + * this 2,500-line component body once per mounted pane. Parents must + * pass referentially stable callbacks for the memo to hold — see + * TerminalTabContent in MainColumn. */ -export function BlockTerminal({ +function BlockTerminalInner({ id, command, args, @@ -212,6 +224,12 @@ export function BlockTerminal({ paneKey = "main", }: Props) { const containerRef = useRef(null); + // Ref mirror of the `isVisible` prop so long-lived intervals (the + // activity-summary poll below) can check current visibility without + // listing it as an effect dep — restarting those effects on every + // tab switch would refetch state the pane deliberately keeps. + const isVisibleRef = useRef(isVisible); + isVisibleRef.current = isVisible; /** * Scroll container for the closed blocks + live block area. Manually * managed instead of relying on column-reverse auto-scroll, because @@ -398,6 +416,12 @@ export function BlockTerminal({ if (!cli) return; let cancelled = false; const tick = async () => { + // Hidden panes (kept alive across worktree switches) and a + // hidden window skip the IPC entirely — with N agents running + // this poll otherwise costs N invokes every 4s around the + // clock. The summary refreshes on the first tick after the + // pane becomes visible again. + if (document.hidden || !isVisibleRef.current) return; try { const summary = await invoke("claude_activity_summary", { projectCwd: cwd, @@ -1770,6 +1794,8 @@ export function BlockTerminal({ if (text.trim().length > 0) { setHistory((prev) => { const next = [text, ...prev]; + // Newest-first ring: drop the oldest entries past the cap. + if (next.length > MAX_HISTORY) next.length = MAX_HISTORY; memSetHistory(id, next); return next; }); @@ -2497,3 +2523,12 @@ export function BlockTerminal({ ); } + +/** + * Memoized export: with the keepalive layer holding N panes mounted, + * shallow-equal props MUST short-circuit the render — otherwise every + * dispatch anywhere in the app re-runs every pane. All props are + * primitives or parent-stabilized callbacks, so React.memo's default + * shallow compare is sufficient. + */ +export const BlockTerminal = memo(BlockTerminalInner); diff --git a/src/terminal/LiveBlock.tsx b/src/terminal/LiveBlock.tsx index 1ef459d..761775b 100644 --- a/src/terminal/LiveBlock.tsx +++ b/src/terminal/LiveBlock.tsx @@ -178,41 +178,46 @@ export function LiveBlock({ const hasBody = displayedRows.length > 0; const cwdLabel = formatCwd(cwd); - // Live duration counter — same look as closed blocks but updated - // every animation frame so the user sees the command time accumulate - // smoothly. The label is written directly into a ref-bound - // via `textContent`, never via React state. This avoids ~10 - // unnecessary commits per second per running command — at 20 active - // panes that's ~200 component-level rerenders/s, all on the React - // critical path. rAF + DOM write puts the work on the compositor - // instead and frees the main thread for actual user input. + // Live duration counter — same look as closed blocks. The label is + // written directly into a ref-bound via `textContent`, never + // via React state, so React never commits these updates. The label + // only changes once per second, so a 1s interval is enough — a rAF + // loop here would wake the CPU 60×/s per running command × N panes + // (including panes hidden via visibility:hidden, which still get + // animation frames). Hidden panes skip the DOM write entirely; the + // label is derived from startRef so it snaps to the right value on + // the first tick after becoming visible again. const startRef = useRef(Date.now()); const durationRef = useRef(null); useEffect(() => { startRef.current = Date.now(); - let cancelled = false; - let raf = 0; let lastLabel = ""; const paint = () => { - if (cancelled) return; + const node = durationRef.current; + if (!node) return; + // visibilityProperty matters: the keepalive layers hide panes + // with `visibility: hidden` (NOT display:none — that would kill + // the WebGPU surface), and argless checkVisibility() does not + // look at the visibility property. + const visible = node.checkVisibility?.({ + visibilityProperty: true, + contentVisibilityAuto: true, + }); + if (document.hidden || visible === false) return; const label = formatDuration(Date.now() - startRef.current); if (label !== lastLabel) { lastLabel = label; - const node = durationRef.current; - if (node) node.textContent = `(${label})`; + node.textContent = `(${label})`; } - raf = requestAnimationFrame(paint); }; // Prime once synchronously so the first paint already shows a - // sensible duration; rAF takes over after that. + // sensible duration; the interval takes over after that. paint(); - return () => { - cancelled = true; - if (raf) cancelAnimationFrame(raf); - }; + const id = window.setInterval(paint, 1000); + return () => window.clearInterval(id); }, [command]); // Initial label at first render. Subsequent updates are written - // directly into `durationRef.current` by the rAF loop — React never + // directly into `durationRef.current` by the interval — React never // commits them. const initialElapsedLabel = formatDuration(Date.now() - startRef.current); diff --git a/src/terminal/TerminalStatusBar.tsx b/src/terminal/TerminalStatusBar.tsx index 56d3dc3..1e18bc9 100644 --- a/src/terminal/TerminalStatusBar.tsx +++ b/src/terminal/TerminalStatusBar.tsx @@ -1,6 +1,6 @@ import { AnimatePresence } from "motion/react"; -import { useEffect, useState, type MouseEvent as ReactMouseEvent } from "react"; -import { git } from "@/lib/git"; +import { useMemo, useState, type MouseEvent as ReactMouseEvent } from "react"; +import { refreshGitStatus, useSharedGitStatus } from "@/state/gitStatusStore"; import { BranchSwitcher } from "@/shell/BranchSwitcher"; import { useClaudeUsage } from "@/lib/claudeUsage"; import { useAppState } from "@/state/AppState"; @@ -29,8 +29,6 @@ interface GitInfo { modified: number; } -const POLL_MS = 4000; - /** * Pill-badge breadcrumb at the BOTTOM of the terminal pane, Warp-style: * @@ -222,80 +220,38 @@ function useGitInfo(cwd: string): { info: GitInfo; refresh: () => Promise; } { - // Track the cwd alongside the info so we can blank stale data the - // moment the prop changes — otherwise the previous worktree's branch - // (typically "main") flashes for up to a poll cycle when the user - // clicks a different worktree in the sidebar. - const [state, setState] = useState<{ cwd: string; info: GitInfo }>({ - cwd, - info: EMPTY_GIT_INFO, - }); - const [trigger, setTrigger] = useState(0); - - // Synchronous reset on cwd change. This runs during render, before - // the post-commit useEffect, so the pill never shows the old branch. - if (state.cwd !== cwd) { - setState({ cwd, info: EMPTY_GIT_INFO }); - } - - useEffect(() => { - if (!cwd) return; - let cancelled = false; - const pull = async () => { - try { - const status = await git.status(cwd); - if (cancelled) return; - let added = 0; - let removed = 0; - let modified = 0; - for (const e of status.entries) { - if (e.kind === "added" || e.kind === "untracked") added++; - else if (e.kind === "deleted") removed++; - else if (e.kind === "modified" || e.kind === "renamed") modified++; - } - setState({ - cwd, - info: { - branch: status.branch, - ahead: status.ahead, - behind: status.behind, - added, - removed, - modified, - }, - }); - } catch { - if (!cancelled) { - setState((prev) => - prev.cwd === cwd - ? { cwd, info: { ...EMPTY_GIT_INFO } } - : prev, - ); - } - } + // Backed by the shared per-cwd git-status store: every status bar + // (and the file tree) polling the same cwd shares ONE `git status` + // subprocess every 4s instead of one per mounted pane, and the poll + // pauses while the window is hidden. Because the snapshot is keyed + // by the current cwd, a cwd change can never show the previous + // worktree's branch — the store returns null until the new cwd's + // first result lands, which renders as the empty pill state. + const status = useSharedGitStatus(cwd || null); + const info = useMemo(() => { + if (!status) return EMPTY_GIT_INFO; + let added = 0; + let removed = 0; + let modified = 0; + for (const e of status.entries) { + if (e.kind === "added" || e.kind === "untracked") added++; + else if (e.kind === "deleted") removed++; + else if (e.kind === "modified" || e.kind === "renamed") modified++; + } + return { + branch: status.branch, + ahead: status.ahead, + behind: status.behind, + added, + removed, + modified, }; - void pull(); - const id = window.setInterval(pull, POLL_MS); - // External nudges (commit+push, merge) so the pill state lands - // immediately instead of lagging the next poll cycle. - const onRefresh = (e: Event) => { - const detail = (e as CustomEvent<{ cwd?: string }>).detail; - if (!detail?.cwd || detail.cwd === cwd) void pull(); - }; - window.addEventListener("goonware-git-refresh", onRefresh); - return () => { - cancelled = true; - window.clearInterval(id); - window.removeEventListener("goonware-git-refresh", onRefresh); - }; - }, [cwd, trigger]); - - const info = state.info; + }, [status]); return { info, refresh: async () => { - setTrigger((t) => t + 1); + refreshGitStatus(cwd); }, }; } diff --git a/src/terminal/WarpSurfaceTracker.tsx b/src/terminal/WarpSurfaceTracker.tsx index a279f28..87c37a1 100644 --- a/src/terminal/WarpSurfaceTracker.tsx +++ b/src/terminal/WarpSurfaceTracker.tsx @@ -121,16 +121,40 @@ export function WarpSurfaceTracker({ // layout flip like split↔full racing a heavy commit has been seen // to leave the native rect stale — terminal stuck at half width, // the rest of the hole showing the black host window). Re-checking - // on a slow interval costs one getBoundingClientRect and sends - // nothing while the rect is unchanged, but guarantees the native - // surface converges to the real DOM box within ~300ms. - const poll = window.setInterval(() => report(), 300); + // on a slow interval costs one getBoundingClientRect (a forced + // layout read, so keep it infrequent) and sends nothing while the + // rect is unchanged, but guarantees the native surface converges + // to the real DOM box within ~1s. RO + window-resize cover every + // normal path instantly; this only catches the rare dropped + // report. Not started at all while this pane reports the zero + // rect (hidden, no reportWhenHidden) or the window is hidden — + // there's nothing to converge to, and per-pane wakeups while + // backgrounded are exactly what drains the battery with many + // panes mounted. The visibilitychange hook re-checks immediately + // on un-hide, which also covers rAF having been paused. + let poll: number | null = null; + const syncPoll = () => { + const wantPoll = (visible || reportWhenHidden) && !document.hidden; + if (wantPoll && poll === null) { + poll = window.setInterval(() => report(), 1000); + } else if (!wantPoll && poll !== null) { + window.clearInterval(poll); + poll = null; + } + }; + const onVisibilityChange = () => { + if (!document.hidden) report(); + syncPoll(); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + syncPoll(); return () => { cancelAnimationFrame(raf); ro.disconnect(); window.removeEventListener("resize", schedule); - window.clearInterval(poll); + document.removeEventListener("visibilitychange", onVisibilityChange); + if (poll !== null) window.clearInterval(poll); }; }, [visible, paneKey, reportWhenHidden]); diff --git a/src/terminal/sessionMemory.ts b/src/terminal/sessionMemory.ts index 43fe255..442125b 100644 --- a/src/terminal/sessionMemory.ts +++ b/src/terminal/sessionMemory.ts @@ -16,14 +16,34 @@ * when the session is permanently deleted, via forgetSession (which * fires term_close for each matching ptyId). * - * Caps: none. Block scrollback and typed-input history grow without - * bound — the user wants to see every row of every agent that ever - * ran in this session. Memory is bounded in practice by how much an - * actual session produces. + * Caps: generous but finite (see MAX_BLOCKS / MAX_SCROLLBACK_ROWS / + * MAX_HISTORY below, enforced at the growth sites in + * useTerminalSession / BlockTerminal). This store used to be + * explicitly unbounded; with many long-running agents each pane + * accumulated an ever-growing second copy of its entire output as JS + * Span objects (far heavier per row than the Rust grid), which never + * shrank for the lifetime of the page. Everything older than the caps + * is still on disk (SQLite blocks table) — the caps only bound what + * stays resident in the webview heap. */ import { invoke } from "@tauri-apps/api/core"; +import { clearTerminalRunning } from "./terminalActivityStore"; import type { Block, RenderFrame, Span } from "./types"; +/** Max closed blocks kept in memory per pty. Matches the restore + * window (`HISTORY_LOAD_WINDOW = 500` in Rust persistence) — the UI + * never pages further back than this without a deeper disk load. */ +export const MAX_BLOCKS = 500; +/** Max live-block scrollback rows mirrored per pty. Rows past this + * scroll out of reach mid-command; the closed block re-renders from + * its full transcript when the command finishes. */ +export const MAX_SCROLLBACK_ROWS = 10_000; +/** Hysteresis for scrollback trimming so the O(n) front-splice runs + * once per ~1k rows instead of per appended row. */ +export const SCROLLBACK_TRIM_CHUNK = 1024; +/** Max typed-command history entries per pty. */ +export const MAX_HISTORY = 1000; + interface Memory { blocks: Block[]; history: string[]; @@ -171,6 +191,9 @@ export function forgetPtys(ptyIds: string[]): void { for (const id of ptyIds) { if (!id) continue; store.delete(id); + // Companion store: drop the per-pty running flag so the activity + // map doesn't accumulate an entry for every pty ever opened. + clearTerminalRunning(id); void invoke("term_close", { id }).catch(() => {}); void invoke("term_history_forget", { id }).catch(() => {}); } diff --git a/src/terminal/terminalActivityStore.ts b/src/terminal/terminalActivityStore.ts index f14dd97..7962847 100644 --- a/src/terminal/terminalActivityStore.ts +++ b/src/terminal/terminalActivityStore.ts @@ -107,10 +107,15 @@ function snapshotByKey(key: string, ptyIds: readonly string[]): boolean { * happening" tempo while keeping the IPC volume well below the * frame-emit budget. The Rust side is O(N) over open sessions and * a few microseconds in practice, so this is cheap. + * + * The poll pauses entirely while the window is hidden (nobody can + * see the spinner) and reconciles immediately on the visibilitychange + * back to visible, so no stale state survives un-hiding. */ const POLL_INTERVAL_MS = 500; let pollHandle: number | null = null; let pollSubscribers = 0; +let visibilityHooked = false; async function pollRunningSessions() { try { @@ -139,6 +144,21 @@ async function pollRunningSessions() { } function ensurePoll() { + if (!visibilityHooked) { + visibilityHooked = true; + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + stopPoll(); + } else if (pollSubscribers > 0) { + startPoll(); + } + }); + } + if (document.hidden) return; + startPoll(); +} + +function startPoll() { if (pollHandle !== null) return; void pollRunningSessions(); pollHandle = window.setInterval( diff --git a/src/terminal/useTerminalSession.ts b/src/terminal/useTerminalSession.ts index 1d511b9..3479c1f 100644 --- a/src/terminal/useTerminalSession.ts +++ b/src/terminal/useTerminalSession.ts @@ -19,6 +19,9 @@ import { setLiveFrame as memSetLiveFrame, setRows as memSetRows, setScrollback as memSetScrollback, + MAX_BLOCKS, + MAX_SCROLLBACK_ROWS, + SCROLLBACK_TRIM_CHUNK, } from "./sessionMemory"; import type { Block, @@ -28,6 +31,14 @@ import type { Span, } from "./types"; +/** + * Monotonic suffix for synthetic block React keys. `prev.length` was + * the old suffix, which stops being unique once the MAX_BLOCKS cap + * pins the list length — two blocks closing in the same millisecond + * would then collide as duplicate React keys. + */ +let nextBlockSeq = 0; + interface Args { /** Stable PTY session id — must be unique per running PTY. */ id: string; @@ -317,7 +328,8 @@ export function useTerminalSession(opts: Args): SessionApi { // and its resolve), merge instead of clobber. Historical // rows go first to preserve chronology. setBlocks((prev) => { - const merged = prev.length > 0 ? [...hydrated, ...prev] : hydrated; + let merged = prev.length > 0 ? [...hydrated, ...prev] : hydrated; + if (merged.length > MAX_BLOCKS) merged = merged.slice(-MAX_BLOCKS); memSetBlocks(opts.id, merged); return merged; }); @@ -425,11 +437,30 @@ export function useTerminalSession(opts: Args): SessionApi { // rows interleaved with the fresh sync. const appended = frame.scrollback_appended ?? []; if (frame.scrollback_reset) { - scrollbackRowsRef.current = appended.map((dr) => dr.spans); + // The reset re-sync ships the backend's FULL history (which + // is unbounded Rust-side), so the cap must apply here too — + // otherwise one term_start re-attach of a long-running agent + // rebuilds the entire mirror the cap exists to bound. + const synced = appended.map((dr) => dr.spans); + scrollbackRowsRef.current = + synced.length > MAX_SCROLLBACK_ROWS + ? synced.slice(synced.length - MAX_SCROLLBACK_ROWS) + : synced; } else if (appended.length > 0) { - const next = scrollbackRowsRef.current.slice(); - for (const dr of appended) next.push(dr.spans); - scrollbackRowsRef.current = next; + // In-place append. This used to `.slice()` the whole mirror + // per frame "for React identity" — but nothing reads this + // array's identity: flushFrame re-wraps it into fresh + // DirtyRow[] on every commit, and the inner Span[] rows are + // immutable once appended. The copy was O(scrollback depth) + // of allocation + GC churn per frame, at its worst exactly + // when a terminal streams hardest. + const sb = scrollbackRowsRef.current; + for (const dr of appended) sb.push(dr.spans); + // Bound the mirror (oldest rows drop first). Trimmed in + // chunks so the front-splice cost amortizes to ~zero. + if (sb.length > MAX_SCROLLBACK_ROWS + SCROLLBACK_TRIM_CHUNK) { + sb.splice(0, sb.length - MAX_SCROLLBACK_ROWS); + } } // Latest frame wins. If multiple events arrive before the next // paint, the rAF flush sees only the most recent metadata @@ -458,10 +489,15 @@ export function useTerminalSession(opts: Args): SessionApi { // guaranteed by the order in which the user pressed Enter. const stamped = pendingInputsRef.current.shift() ?? b.input; setBlocks((prev) => { + // Cap the in-memory block list to the restore window — the + // oldest block drops here but stays in SQLite, exactly like + // a block that was never re-loaded after restart. + const capped = + prev.length >= MAX_BLOCKS ? prev.slice(prev.length - MAX_BLOCKS + 1) : prev; const next = [ - ...prev, + ...capped, { - id: `b_${Date.now().toString(36)}_${prev.length}`, + id: `b_${Date.now().toString(36)}_${nextBlockSeq++}`, block_id: b.block_id, input: stamped, transcript: b.transcript, From 90d777f32e7754068a03be62721fa9dba9d13871 Mon Sep 17 00:00:00 2001 From: raeedz Date: Thu, 23 Jul 2026 18:12:48 -0700 Subject: [PATCH 2/2] PR draft: gather full branch context, not just working-tree diff Summarize commits + cumulative diff vs the base branch (via merge-base) alongside staged/unstaged changes, so already-committed-and-pushed work still surfaces instead of yielding an empty description. Errors out when there's genuinely nothing to describe. --- src-tauri/src/pr.rs | 193 +++++++++++++++++++++++++++++++++++++-- src/shell/RightPanel.tsx | 26 +++++- 2 files changed, 208 insertions(+), 11 deletions(-) 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/shell/RightPanel.tsx b/src/shell/RightPanel.tsx index 00d893f..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 { @@ -642,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, @@ -704,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 { @@ -869,6 +887,7 @@ function ChangesView({ >
; message: string; onChange: (s: string) => void; onDraft: () => void; @@ -1245,6 +1266,7 @@ function CommitComposer({ }} >