diff --git a/crates/rmux-app/src/app.rs b/crates/rmux-app/src/app.rs index 03931bc..d5f9d6e 100644 --- a/crates/rmux-app/src/app.rs +++ b/crates/rmux-app/src/app.rs @@ -83,6 +83,8 @@ pub struct RmuxApp { pub(crate) config: Config, /// Shared workspace wallpaper (one image behind every pane). pub(crate) wallpaper: Wallpaper, + /// Background worker for `ps` / `git` / `gh` / cwd probes. + title_probe: crate::workspace::probe::TitleProbe, } /// Peek at the saved session's window size before the egui window opens. @@ -214,6 +216,7 @@ impl RmuxApp { pending_window_size, config, wallpaper: Wallpaper::new(), + title_probe: crate::workspace::probe::TitleProbe::spawn(), }; // Load wallpaper texture early if configured. @@ -264,6 +267,14 @@ impl RmuxApp { if self.is_applying_session_restore { return; } + // Stamp the attempt *before* any early return. Previously this was only + // set after a successful write, so once the fingerprint stabilised (the + // steady state — nothing about the layout changes while you type) the + // interval check passed on every single frame and `capture_session` ran + // at 60 Hz. Capture walks every pane, which used to fork `ps` per pane, + // so the UI thread spent whole frames waiting on subprocesses and the + // OS flagged the app as unresponsive. + self.last_session_save_at = Instant::now(); let inner_size = ctx .map(|c| { let rect = c.input(|i| i.screen_rect()); @@ -287,7 +298,6 @@ impl RmuxApp { match self.session_store.save(&snap) { Ok(()) => { self.last_session_fingerprint = Some(fp); - self.last_session_save_at = Instant::now(); tracing::debug!( workspaces = snap.workspaces.len(), path = %self.session_store.primary_path().display(), @@ -358,6 +368,18 @@ impl RmuxApp { self.is_applying_session_restore = false; } + /// How long the loop may idle before the next animated frame is needed. + /// + /// Currently driven only by the cursor blink; a session autosave tick is + /// also honoured so the timer can never delay a pending write past its + /// interval. + fn next_animation_frame_delay(&self) -> Duration { + let blink = crate::ui::CURSOR_BLINK_HALF_PERIOD; + let autosave = Duration::from_secs(self.session_autosave_secs.max(2)); + let until_autosave = autosave.saturating_sub(self.last_session_save_at.elapsed()); + blink.min(until_autosave).max(Duration::from_millis(16)) + } + fn maybe_autosave_session(&mut self, ctx: &egui::Context) { let interval = Duration::from_secs(self.session_autosave_secs.max(2)); if self.last_session_save_at.elapsed() < interval { @@ -445,8 +467,16 @@ impl eframe::App for RmuxApp { if self.workspace_manager.process_all_panes() { ctx.request_repaint(); } - // cmux-style dynamic sidebar titles from focused process / path. - self.workspace_manager.refresh_auto_titles(); + + // cmux-style dynamic sidebar titles. The underlying `ps` / `git` / `gh` + // work happens on the probe thread; here we only adopt its results and + // recompute the (string-only) sidebar aggregates when they change. + if let Some(probes) = self.title_probe.take_fresh() + && self.workspace_manager.apply_probe_results(&probes) + { + self.workspace_manager.refresh_auto_titles(); + } + self.title_probe.maybe_request(self.workspace_manager.all_shell_pids()); // Consume app shortcuts BEFORE UI so reserved chords never reach the // terminal PTY. On Linux egui sets both `ctrl` and `command` for Ctrl; @@ -496,8 +526,17 @@ impl eframe::App for RmuxApp { // Handle any pending socket API requests on the main thread self.process_api_requests(); - // Request continuous repaints for terminal updates (PTY output, cursor blink) - ctx.request_repaint_after(std::time::Duration::from_millis(16)); + // Keep the caret blinking without pinning the app at 60 fps forever. + // + // A blanket `request_repaint_after(16ms)` meant rmux re-applied the + // theme, re-snapshotted every visible grid and rebuilt sidebar strings + // sixty times a second even when the screen was completely static — + // one core of pure overhead, and enough contention to add jitter to + // key→screen latency. PTY output already wakes the loop immediately + // (the reader thread calls `request_repaint`), keyboard/scroll request + // their own repaint, and a pending reflow owns frames while it settles. + // So the only thing left needing a timer is the blink. + ctx.request_repaint_after(self.next_animation_frame_delay()); // Render the top bar and status bar first so they span the full // window width (egui panel order: top/bottom before side panels). @@ -881,6 +920,10 @@ impl RmuxApp { self.last_active_workspace = id; let index = self.workspace_manager.active_index(); self.publish_event("workspace.changed", json!({ "id": id, "index": index })); + // Titles otherwise only refresh when a probe batch lands (~1 s); + // recompute now so a switch updates the sidebar immediately. This + // is pure string work over already-cached probe data. + self.workspace_manager.refresh_auto_titles(); } } diff --git a/crates/rmux-app/src/ui/mod.rs b/crates/rmux-app/src/ui/mod.rs index 044b481..7c89dcc 100644 --- a/crates/rmux-app/src/ui/mod.rs +++ b/crates/rmux-app/src/ui/mod.rs @@ -27,5 +27,7 @@ pub mod workspace_view; pub use help_menu::HelpMenu; pub use notification_panel::NotificationPanel; pub use settings_panel::SettingsPanel; -pub use terminal_pane::{DEFAULT_FONT_SIZE, TerminalPane, format_cwd_tab_title}; +pub use terminal_pane::{ + CURSOR_BLINK_HALF_PERIOD, DEFAULT_FONT_SIZE, TerminalPane, format_cwd_tab_title, +}; pub use wallpaper::Wallpaper; diff --git a/crates/rmux-app/src/ui/terminal_pane.rs b/crates/rmux-app/src/ui/terminal_pane.rs index fce4b11..19b0e9e 100644 --- a/crates/rmux-app/src/ui/terminal_pane.rs +++ b/crates/rmux-app/src/ui/terminal_pane.rs @@ -36,6 +36,16 @@ impl RepaintHandle { ctx.request_repaint(); } } + + /// Schedule a frame `delay` from now (used for deadlines that must fire + /// even when the PTY stays silent). + fn request_after(&self, delay: std::time::Duration) { + if let Ok(slot) = self.ctx.lock() + && let Some(ctx) = slot.as_ref() + { + ctx.request_repaint_after(delay); + } + } } /// The default font size for terminal text. @@ -67,6 +77,8 @@ pub struct TerminalPane { state: TermState, /// The terminal grid renderer. renderer: TerminalRenderer, + /// Reused grid snapshot buffer (avoids reallocating the cell grid per frame). + snapshot: rmux_terminal::GridSnapshot, /// Input mapper for keyboard events. input_mapper: InputMapper, /// Channel receiver for PTY output from background thread. @@ -88,19 +100,17 @@ pub struct TerminalPane { exit_message: Option, /// Whether the process exited successfully (code 0, no signal). exit_success: bool, - /// Cached shell cwd for tab titles (refreshed periodically; avoids - /// calling `lsof` / reading `/proc` every frame). + /// Cached shell cwd for tab titles. Filled by the background probe + /// worker — never by a `lsof` / `/proc` read on the UI thread. last_cwd: Option, - /// Frame counter used to throttle cwd / process-title refreshes. - cwd_tick: u16, /// Cached foreground process title (`cargo run …`), when the shell is busy. last_fg_title: Option, + /// Cached full foreground command line (session agent-resume capture). + last_fg_args: Option, /// Cached git branch for the shell cwd (idle workspace title). last_git_branch: Option, - /// Cached PR chip for the shell cwd (throttled; cmux pull-request row). + /// Cached PR chip for the shell cwd (cmux pull-request row). last_pull_request: Option, - /// Frames since last PR probe (PR probe is slower than cwd/`ps`). - pr_tick: u16, // Find bar state /// Whether the find/search bar is currently visible. @@ -145,12 +155,20 @@ pub struct TerminalPane { struct PendingStartup { /// Full command line without trailing newline. command: String, - /// Frames waited since queue (shell may need a moment to print PS1). - frames: u16, + /// When the command was queued. Wall-clock rather than a frame count: the + /// app no longer repaints at a fixed 60 Hz, so "45 frames" was no longer a + /// meaningful delay for a shell that prints nothing. + queued_at: std::time::Instant, /// True once any PTY output has been observed. saw_output: bool, } +/// Grace period after the shell's first output before typing a resume command. +const STARTUP_CMD_GRACE: std::time::Duration = std::time::Duration::from_millis(140); + +/// Hard deadline for sending a resume command even if the shell stays silent. +const STARTUP_CMD_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(750); + impl TerminalPane { /// Spawn a new terminal pane with a shell process (default `$HOME` cwd). #[allow(dead_code)] // convenience wrapper; call sites use `spawn_with_cwd` @@ -192,6 +210,7 @@ impl TerminalPane { backend, state, renderer, + snapshot: rmux_terminal::GridSnapshot::default(), input_mapper, pty_rx: rx, repaint, @@ -205,11 +224,10 @@ impl TerminalPane { // Prefer the spawn cwd so tab labels are correct before the first // process-query refresh. last_cwd: cwd.map(Path::to_path_buf), - cwd_tick: 0, last_fg_title: None, + last_fg_args: None, last_git_branch: None, last_pull_request: None, - pr_tick: 0, find_visible: false, find_query: String::new(), find_results: Vec::new(), @@ -240,13 +258,39 @@ impl TerminalPane { if cmd.is_empty() { return; } - self.pending_startup = - Some(PendingStartup { command: cmd.to_owned(), frames: 0, saw_output: false }); + self.pending_startup = Some(PendingStartup { + command: cmd.to_owned(), + queued_at: std::time::Instant::now(), + saw_output: false, + }); } - /// Full untruncated foreground process args (for agent resume capture). - pub fn foreground_process_args(&self) -> Option { - self.backend.foreground_process_args() + /// OS pid of this pane's shell, for the background probe worker. + pub fn shell_pid(&self) -> Option { + self.backend.process_id() + } + + /// Cached full foreground command line (agent resume capture). + /// + /// Read-only: filled by [`Self::apply_probe`]. Session capture used to call + /// straight into `ps` here, which — combined with the autosave timer bug — + /// forked a process per pane *per frame*. + pub fn foreground_process_args(&self) -> Option<&str> { + self.last_fg_args.as_deref() + } + + /// Adopt fresh metadata from the background probe worker. + /// + /// Only overwrites `cwd` when the probe produced one, so a pane keeps its + /// spawn directory if the process query transiently fails. + pub fn apply_probe(&mut self, probe: &crate::workspace::probe::PaneProbe) { + if probe.cwd.is_some() { + self.last_cwd = probe.cwd.clone(); + } + self.last_fg_title = probe.fg_title.clone(); + self.last_fg_args = probe.fg_args.clone(); + self.last_git_branch = probe.git_branch.clone(); + self.last_pull_request = probe.pull_request.clone(); } /// Process any new PTY output from the background reader thread. @@ -288,31 +332,33 @@ impl TerminalPane { } } - // Title probes shell out to `ps` / `lsof` / `git` / `gh` — never do - // that on the hot path while the user is typing in this pane. - self.cwd_tick = self.cwd_tick.wrapping_add(1); - let probe_every = if self.has_focus { 120 } else { 45 }; - if self.last_cwd.is_none() { - self.refresh_title_sources(false); - } else if self.cwd_tick.is_multiple_of(probe_every) { - self.refresh_title_sources(self.has_focus); - } + // Title metadata (`ps` / `lsof` / `git` / `gh`) is produced by the + // background probe worker and handed over via [`Self::apply_probe`] — + // this function must never fork a process. got_output } /// Send a queued startup command once the shell looks ready. /// - /// Waits until PTY output has been seen (prompt printed) **or** ~20 frames - /// (~330 ms at 60 fps) so slow shells still get the resume command. + /// Waits until PTY output has been seen (prompt printed) plus a short grace + /// period, or until [`STARTUP_CMD_TIMEOUT`] so silent shells still get it. fn try_flush_startup_command(&mut self) { - let Some(pending) = self.pending_startup.as_mut() else { + let Some(pending) = self.pending_startup.as_ref() else { return; }; - pending.frames = pending.frames.saturating_add(1); - // Ready: saw shell output and waited a couple frames for PS1, or timeout. - let ready = (pending.saw_output && pending.frames >= 8) || pending.frames >= 45; + let waited = pending.queued_at.elapsed(); + let ready = + (pending.saw_output && waited >= STARTUP_CMD_GRACE) || waited >= STARTUP_CMD_TIMEOUT; if !ready { + // Own a wake-up so the deadline still fires for a silent shell — + // the app loop no longer ticks every 16 ms. + let next = if pending.saw_output { + STARTUP_CMD_GRACE.saturating_sub(waited) + } else { + STARTUP_CMD_TIMEOUT.saturating_sub(waited) + }; + self.repaint.request_after(next.max(std::time::Duration::from_millis(10))); return; } let Some(pending) = self.pending_startup.take() else { @@ -328,46 +374,12 @@ impl TerminalPane { } } - /// Probe cwd, foreground process, and git branch (throttled by caller). - /// - /// When `skip_slow` is true (focused typing), skip `git`/`gh`/`ps`-heavy - /// work that can stall the UI thread for tens–hundreds of ms. - fn refresh_title_sources(&mut self, skip_slow: bool) { - let mut cwd_changed = false; - if let Some(cwd) = self.backend.working_directory() { - cwd_changed = self.last_cwd.as_ref() != Some(&cwd); - self.last_cwd = Some(cwd); - if !skip_slow - && (cwd_changed || self.last_git_branch.is_none()) - && let Some(ref path) = self.last_cwd - { - self.last_git_branch = crate::workspace::title::git_branch_for_cwd(path); - } - } else if self.last_cwd.is_none() { - self.last_cwd = self.backend.working_directory(); - } - - if !skip_slow { - self.last_fg_title = self.backend.foreground_process_title(); - } - - // PR probe ~every 180 frames (~3s) or when cwd changes — `gh` is slow. - self.pr_tick = self.pr_tick.wrapping_add(1); - if !skip_slow && (cwd_changed || self.pr_tick.is_multiple_of(180)) { - if let Some(ref path) = self.last_cwd { - self.last_pull_request = - crate::workspace::sidebar_snapshot::pull_request_for_cwd(path); - } else { - self.last_pull_request = None; - } - } - } - /// Best-effort current working directory of this pane's shell. /// - /// Used when spawning a sibling tab/split so the new shell opens in - /// the same directory the user has already navigated to. Prefers the - /// cached value, falling back to a live query. + /// Used when spawning a sibling tab/split so the new shell opens in the + /// directory the user has already navigated to. Prefers the probed value; + /// the live fallback only runs when nothing has been probed yet (a brand + /// new pane), because on macOS it shells out to `lsof`. pub fn working_directory(&self) -> Option { self.last_cwd.clone().or_else(|| self.backend.working_directory()) } @@ -519,13 +531,15 @@ impl TerminalPane { self.show_title_bar(ui, rect); } - // Take a snapshot of the terminal grid and render it - let snapshot = self.state.snapshot(); - self.renderer.draw(ui, rect, &snapshot, self.show_cursor); + // Snapshot the grid into the pane's reusable buffer, then render it. + self.state.snapshot_into(&mut self.snapshot); + self.renderer.draw(ui, rect, &self.snapshot, self.show_cursor); // Highlight find matches in the terminal if self.find_visible && !self.find_query.is_empty() { + let snapshot = std::mem::take(&mut self.snapshot); self.highlight_matches(ui, rect, &snapshot); + self.snapshot = snapshot; } // Focus is indicated by dimming *inactive* panes in the workspace @@ -1166,7 +1180,15 @@ pub fn format_cwd_tab_title(cwd: &Path) -> String { } /// `user@hostname` for home-directory tabs (matches cmux default title). +/// +/// Computed once — this runs from `tab_label()`, i.e. potentially every frame +/// for every pane, and the fallback branch forks `hostname`. fn user_host_title() -> String { + static CACHED: std::sync::OnceLock = std::sync::OnceLock::new(); + CACHED.get_or_init(compute_user_host_title).clone() +} + +fn compute_user_host_title() -> String { let user = std::env::var("USER") .or_else(|_| std::env::var("LOGNAME")) .unwrap_or_else(|_| "user".to_owned()); @@ -1582,6 +1604,11 @@ impl TerminalPane { /// Half-period of the terminal caret blink, in seconds (on + off ≈ 1 s). const CURSOR_BLINK_HALF_PERIOD_SECS: f64 = 0.5; +/// [`CURSOR_BLINK_HALF_PERIOD_SECS`] as a [`std::time::Duration`], used by the +/// app loop to pick its idle repaint interval. +pub const CURSOR_BLINK_HALF_PERIOD: std::time::Duration = + std::time::Duration::from_millis((CURSOR_BLINK_HALF_PERIOD_SECS * 1000.0) as u64); + /// Whether the terminal caret should be painted this frame. /// /// When focused, blinks at ~1 Hz (500 ms on / 500 ms off) using the fractional diff --git a/crates/rmux-app/src/workspace/mod.rs b/crates/rmux-app/src/workspace/mod.rs index 6fb5f84..bd354e4 100644 --- a/crates/rmux-app/src/workspace/mod.rs +++ b/crates/rmux-app/src/workspace/mod.rs @@ -11,6 +11,7 @@ pub mod agent_resume; pub mod model; +pub mod probe; pub mod session; pub mod sidebar_snapshot; pub mod splits; @@ -333,6 +334,42 @@ impl WorkspaceManager { any } + /// Shell pids of every terminal in every workspace (probe batch input). + pub fn all_shell_pids(&self) -> Vec { + let mut pids = Vec::new(); + for workspace in &self.workspaces { + workspace.root.for_each_terminal(&mut |term| { + if let Some(pid) = term.shell_pid() { + pids.push(pid); + } + }); + } + pids + } + + /// Push background probe results into the panes they belong to. + /// + /// Returns `true` when at least one pane was updated, so the caller can + /// refresh sidebar aggregates only when something actually changed. + pub fn apply_probe_results( + &mut self, + results: &std::collections::HashMap, + ) -> bool { + if results.is_empty() { + return false; + } + let mut any = false; + for workspace in &mut self.workspaces { + workspace.root.for_each_terminal_mut(&mut |term| { + if let Some(probe) = term.shell_pid().and_then(|pid| results.get(&pid)) { + term.apply_probe(probe); + any = true; + } + }); + } + any + } + /// Close terminals whose process has exited. /// /// Order of operations per workspace: diff --git a/crates/rmux-app/src/workspace/probe.rs b/crates/rmux-app/src/workspace/probe.rs new file mode 100644 index 0000000..24519a4 --- /dev/null +++ b/crates/rmux-app/src/workspace/probe.rs @@ -0,0 +1,306 @@ +//! Off-thread title/metadata probes (`ps`, `/proc`, `lsof`, `git`, `gh`). +//! +//! Everything in here used to run **inline on the egui update thread**, once +//! per pane, every ~45 frames — plus once per frame per pane whenever the +//! session autosave fingerprint was unchanged. Each probe forks a process: +//! +//! * `ps -ax -o pid=,ppid=,args=` — tens of ms on a busy Linux box +//! * `/proc//cwd` (Linux) or `lsof` (macOS) — `lsof` is ~50–150 ms +//! * `git rev-parse` — cold-cache repos are slow +//! * `gh pr view` — a **network** call; seconds when GitHub or DNS is slow +//! +//! With a handful of panes that is enough to blow the frame budget every +//! second (nvim `j/k` and agent prompt typing visibly hitch) and enough to +//! trip the OS "application is not responding" watchdog. +//! +//! This module moves all of it onto a single worker thread: +//! +//! * The UI enqueues the list of live shell pids about once a second. +//! * The worker takes **one** `ps` snapshot for the whole batch instead of +//! one fork per pane. +//! * `git` / `gh` results are memoised per directory, with `gh` on a long +//! interval and an automatic back-off when it turns out to be slow. +//! * Results land in a shared map; the UI thread only ever takes a lock and +//! clones cached strings. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, mpsc}; +use std::time::{Duration, Instant}; + +use super::sidebar_snapshot::{PullRequestDisplay, pull_request_for_cwd}; +use super::title::git_branch_for_cwd; + +/// How often the UI asks for a fresh batch of probes. +pub const PROBE_INTERVAL: Duration = Duration::from_millis(900); + +/// Re-run `git rev-parse` for a directory at most this often. +const GIT_TTL: Duration = Duration::from_secs(3); + +/// Re-resolve a process cwd at most this often. +/// +/// Linux reads a `/proc//cwd` symlink, which is essentially free, so it +/// refreshes every batch. macOS has to fork `lsof` (~100 ms), so it is rate +/// limited — tab labels lag a `cd` by a couple of seconds instead of burning a +/// process per pane per second. +const CWD_TTL: Duration = + if cfg!(target_os = "macos") { Duration::from_millis(2500) } else { Duration::ZERO }; + +/// Re-run `gh pr view` for a directory at most this often. +const PR_TTL: Duration = Duration::from_secs(120); + +/// A single `gh` invocation slower than this marks the directory as slow. +const PR_SLOW_THRESHOLD: Duration = Duration::from_secs(3); + +/// How long a slow directory is skipped for `gh` probes. +const PR_BACKOFF: Duration = Duration::from_secs(600); + +/// Everything the UI wants to know about one shell process. +#[derive(Debug, Clone, Default)] +pub struct PaneProbe { + /// Shell cwd (tab labels, new-split inheritance). + pub cwd: Option, + /// Full untruncated foreground command line (agent resume capture). + pub fg_args: Option, + /// Sidebar-friendly truncated foreground command. + pub fg_title: Option, + /// Git branch for `cwd`. + pub git_branch: Option, + /// PR chip for `cwd`. + pub pull_request: Option, +} + +/// Handle to the background probe worker. +/// +/// Cloning is cheap; the UI keeps one instance in [`crate::app::RmuxApp`]. +pub struct TitleProbe { + tx: mpsc::Sender>, + results: Arc>>, + busy: Arc, + /// Bumped once per completed batch so the UI can skip unchanged frames. + generation: Arc, + /// Generation the UI has already applied. + applied_generation: u64, + last_request: Instant, +} + +impl TitleProbe { + /// Spawn the worker thread. + pub fn spawn() -> Self { + let (tx, rx) = mpsc::channel::>(); + let results: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let busy = Arc::new(AtomicBool::new(false)); + + let generation = Arc::new(AtomicU64::new(0)); + + let worker_results = Arc::clone(&results); + let worker_busy = Arc::clone(&busy); + let worker_generation = Arc::clone(&generation); + std::thread::Builder::new() + .name("rmux-title-probe".into()) + .spawn(move || { + let mut cache = DirCache::default(); + while let Ok(pids) = rx.recv() { + let batch = probe_batch(&pids, &mut cache); + if let Ok(mut slot) = worker_results.lock() { + // Replace wholesale so pids of closed panes drop out. + *slot = batch; + } + worker_generation.fetch_add(1, Ordering::Release); + worker_busy.store(false, Ordering::Release); + } + }) + .ok(); + + // `last_request` starts in the past so the first frame kicks a probe. + Self { + tx, + results, + busy, + generation, + applied_generation: 0, + last_request: Instant::now().checked_sub(PROBE_INTERVAL).unwrap_or_else(Instant::now), + } + } + + /// Enqueue `pids` if the interval elapsed and no batch is in flight. + /// + /// Returns `true` when a batch was submitted. + pub fn maybe_request(&mut self, pids: Vec) -> bool { + if pids.is_empty() { + return false; + } + if self.last_request.elapsed() < PROBE_INTERVAL { + return false; + } + // Never queue a second batch behind a slow `gh` / `ps`. + if self.busy.swap(true, Ordering::AcqRel) { + return false; + } + self.last_request = Instant::now(); + if self.tx.send(pids).is_err() { + self.busy.store(false, Ordering::Release); + return false; + } + true + } + + /// Take the latest results, but only once per completed batch. + /// + /// Returns `None` when the UI has already seen the newest batch, so the + /// caller can skip rebuilding sidebar aggregates on every frame. + pub fn take_fresh(&mut self) -> Option> { + let current = self.generation.load(Ordering::Acquire); + if current == self.applied_generation { + return None; + } + self.applied_generation = current; + Some(self.results.lock().map(|m| m.clone()).unwrap_or_default()) + } +} + +/// Per-directory memo for the slow `git` / `gh` probes. +#[derive(Default)] +struct DirCache { + /// Per-pid cwd, rate limited by [`CWD_TTL`]. + cwd: HashMap)>, + git: HashMap)>, + pr: HashMap)>, + /// Directories where `gh` was slow; skipped until the instant passes. + pr_backoff: HashMap, +} + +impl DirCache { + fn process_cwd(&mut self, pid: u32) -> Option { + if !CWD_TTL.is_zero() + && let Some((at, value)) = self.cwd.get(&pid) + && at.elapsed() < CWD_TTL + { + return value.clone(); + } + let value = rmux_terminal::process_cwd(pid); + self.cwd.insert(pid, (Instant::now(), value.clone())); + value + } + + fn git_branch(&mut self, cwd: &Path) -> Option { + if let Some((at, value)) = self.git.get(cwd) + && at.elapsed() < GIT_TTL + { + return value.clone(); + } + let value = git_branch_for_cwd(cwd); + self.git.insert(cwd.to_path_buf(), (Instant::now(), value.clone())); + value + } + + fn pull_request(&mut self, cwd: &Path) -> Option { + if let Some((at, value)) = self.pr.get(cwd) + && at.elapsed() < PR_TTL + { + return value.clone(); + } + if let Some(until) = self.pr_backoff.get(cwd) { + if Instant::now() < *until { + // Keep serving the last known value while backing off. + return self.pr.get(cwd).and_then(|(_, v)| v.clone()); + } + self.pr_backoff.remove(cwd); + } + + let started = Instant::now(); + let value = pull_request_for_cwd(cwd); + let elapsed = started.elapsed(); + if elapsed >= PR_SLOW_THRESHOLD { + tracing::debug!( + dir = %cwd.display(), + ms = elapsed.as_millis(), + "`gh pr view` was slow; backing off" + ); + self.pr_backoff.insert(cwd.to_path_buf(), Instant::now() + PR_BACKOFF); + } + self.pr.insert(cwd.to_path_buf(), (Instant::now(), value.clone())); + value + } + + /// Forget directories / pids no longer in use so the maps stay small. + fn retain(&mut self, live: &[PathBuf], live_pids: &[u32]) { + self.cwd.retain(|k, _| live_pids.contains(k)); + self.git.retain(|k, _| live.contains(k)); + self.pr.retain(|k, _| live.contains(k)); + self.pr_backoff.retain(|k, _| live.contains(k)); + } +} + +/// Probe every pid using a single `ps` snapshot, then per-directory metadata. +fn probe_batch(pids: &[u32], cache: &mut DirCache) -> HashMap { + let rows = rmux_terminal::process_table(); + let mut out = HashMap::with_capacity(pids.len()); + let mut live_dirs = Vec::new(); + + for &pid in pids { + let fg_args = rmux_terminal::pick_foreground_args(pid, &rows); + let fg_title = fg_args.as_deref().map(rmux_terminal::clean_process_title); + let cwd = cache.process_cwd(pid); + + let (git_branch, pull_request) = match cwd.as_deref() { + Some(dir) => { + live_dirs.push(dir.to_path_buf()); + let branch = cache.git_branch(dir); + // Only worth asking GitHub about actual repositories. + let pr = if branch.is_some() { cache.pull_request(dir) } else { None }; + (branch, pr) + } + None => (None, None), + }; + + out.insert(pid, PaneProbe { cwd, fg_args, fg_title, git_branch, pull_request }); + } + + cache.retain(&live_dirs, pids); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn probe_of_own_pid_reports_a_cwd() { + // The probe worker path is exercised directly (no thread) so the test + // stays deterministic: our own process must have a resolvable cwd on + // Linux and macOS. + let mut cache = DirCache::default(); + let pid = std::process::id(); + let out = probe_batch(&[pid], &mut cache); + let entry = out.get(&pid).expect("entry for own pid"); + if cfg!(any(target_os = "linux", target_os = "macos")) { + assert!(entry.cwd.is_some(), "own cwd should resolve"); + } + } + + #[test] + fn empty_request_is_not_submitted() { + let mut probe = TitleProbe::spawn(); + assert!(!probe.maybe_request(Vec::new())); + } + + #[test] + fn request_is_rate_limited() { + let mut probe = TitleProbe::spawn(); + assert!(probe.maybe_request(vec![std::process::id()])); + // Immediate second call is inside the interval → refused. + assert!(!probe.maybe_request(vec![std::process::id()])); + } + + #[test] + fn dir_cache_retain_drops_unused_entries() { + let mut cache = DirCache::default(); + cache.git.insert(PathBuf::from("/a"), (Instant::now(), Some("main".into()))); + cache.git.insert(PathBuf::from("/b"), (Instant::now(), None)); + cache.retain(&[PathBuf::from("/a")], &[]); + assert!(cache.git.contains_key(Path::new("/a"))); + assert!(!cache.git.contains_key(Path::new("/b"))); + } +} diff --git a/crates/rmux-app/src/workspace/session.rs b/crates/rmux-app/src/workspace/session.rs index 5804e77..6b9b428 100644 --- a/crates/rmux-app/src/workspace/session.rs +++ b/crates/rmux-app/src/workspace/session.rs @@ -486,8 +486,10 @@ fn capture_terminal_surface( title_is_custom: bool, term: &TerminalPane, ) -> SurfaceSnapshot { + // Cached (probe-thread) value — capture runs on the UI thread, so it must + // not fork `ps` here. let (agent_kind, resume_command) = match term.foreground_process_args() { - Some(args) => match super::agent_resume::resume_from_process_args(&args) { + Some(args) => match super::agent_resume::resume_from_process_args(args) { Some(r) => (Some(r.kind.to_owned()), Some(r.command)), None => (None, None), }, diff --git a/crates/rmux-terminal/src/backend.rs b/crates/rmux-terminal/src/backend.rs index f22d2ba..ce4a53c 100644 --- a/crates/rmux-terminal/src/backend.rs +++ b/crates/rmux-terminal/src/backend.rs @@ -434,16 +434,42 @@ pub fn foreground_process_args(shell_pid: u32) -> Option { #[cfg(unix)] fn foreground_process_args_unix(shell_pid: u32) -> Option { - let output = - std::process::Command::new("ps").args(["-ax", "-o", "pid=,ppid=,args="]).output().ok()?; - if !output.status.success() { - return None; - } - let text = String::from_utf8_lossy(&output.stdout); - let rows = parse_ps_pid_ppid_args(&text); + let rows = process_table(); pick_foreground_args(shell_pid, &rows) } +/// One `ps` snapshot of the whole process table as `(pid, ppid, args)`. +/// +/// Forking `ps` costs tens of milliseconds on a busy machine, so callers that +/// need titles for several shells must take **one** snapshot and feed it to +/// [`pick_foreground_args`] per pid instead of probing each pid separately. +/// Never call this from the UI thread — see `rmux_app::workspace::probe`. +pub fn process_table() -> Vec<(u32, u32, String)> { + #[cfg(unix)] + { + let Ok(output) = + std::process::Command::new("ps").args(["-ax", "-o", "pid=,ppid=,args="]).output() + else { + return Vec::new(); + }; + if !output.status.success() { + return Vec::new(); + } + parse_ps_pid_ppid_args(&String::from_utf8_lossy(&output.stdout)) + } + #[cfg(not(unix))] + { + Vec::new() + } +} + +/// Best-effort working directory of an arbitrary process by PID. +/// +/// Exposed for the background probe worker; blocking on macOS (`lsof`). +pub fn process_cwd(pid: u32) -> Option { + cwd_of_process(pid) +} + /// Parse `ps -o pid=,ppid=,args=` lines into `(pid, ppid, args)`. pub fn parse_ps_pid_ppid_args(stdout: &str) -> Vec<(u32, u32, String)> { let mut rows = Vec::new(); diff --git a/crates/rmux-terminal/src/glyph_cache.rs b/crates/rmux-terminal/src/glyph_cache.rs index 178d84c..425826d 100644 --- a/crates/rmux-terminal/src/glyph_cache.rs +++ b/crates/rmux-terminal/src/glyph_cache.rs @@ -31,15 +31,42 @@ impl GlyphKey { #[derive(Default)] pub(crate) struct GlyphCache { map: HashMap>, + /// Memoised `has_glyph` answers per `(char, bold)`. + coverage: HashMap<(char, bool), bool>, } impl GlyphCache { pub(crate) fn with_capacity(cap: usize) -> Self { - Self { map: HashMap::with_capacity(cap) } + Self { map: HashMap::with_capacity(cap), coverage: HashMap::with_capacity(cap) } } pub(crate) fn clear(&mut self) { self.map.clear(); + self.coverage.clear(); + } + + /// Whether the font cascade can render `c`, cached across frames. + /// + /// `Fonts::has_glyph` takes the shared font lock and walks the family's + /// fallback chain. Box-drawing borders (agent prompt boxes) and Nerd Font + /// icons (nvim file tree) are non-ASCII, so the uncached version ran that + /// lookup for hundreds of cells *per frame* — the single largest cost in + /// the paint loop for exactly the two workloads users report as laggy. + pub(crate) fn has_glyph( + &mut self, + ui: &Ui, + c: char, + bold: bool, + font_regular: &FontId, + font_bold: &FontId, + ) -> bool { + if let Some(&known) = self.coverage.get(&(c, bold)) { + return known; + } + let font_id = if bold { font_bold } else { font_regular }; + let has = ui.fonts(|f| f.has_glyph(font_id, c)); + self.coverage.insert((c, bold), has); + has } pub(crate) fn get_or_layout( diff --git a/crates/rmux-terminal/src/lib.rs b/crates/rmux-terminal/src/lib.rs index b9a48b8..3936616 100644 --- a/crates/rmux-terminal/src/lib.rs +++ b/crates/rmux-terminal/src/lib.rs @@ -26,6 +26,7 @@ mod theme; pub use backend::{ PtyBackend, PtyError, PtyResult, clean_process_title, foreground_process_args, foreground_process_title, parse_ps_pid_ppid_args, pick_foreground_args, pick_foreground_title, + process_cwd, process_table, }; pub use coalesced_size::CoalescedSize; pub use input::InputMapper; diff --git a/crates/rmux-terminal/src/renderer.rs b/crates/rmux-terminal/src/renderer.rs index a12986e..f3fb838 100644 --- a/crates/rmux-terminal/src/renderer.rs +++ b/crates/rmux-terminal/src/renderer.rs @@ -631,14 +631,15 @@ impl TerminalRenderer { if is_special_shape(cell.c) { paint_special_shape(&painter, cell_rect, cell.c, cell.fg); } else { - let use_fallback = if cell.c.is_ascii() { - false - } else { - let font_id = - if cell.bold { font_bold.clone() } else { font_regular.clone() }; - let has = ui.fonts(|f| f.has_glyph(&font_id, cell.c)); - !has && is_symbol_range(cell.c) - }; + let use_fallback = !cell.c.is_ascii() + && is_symbol_range(cell.c) + && !self.glyph_cache.has_glyph( + ui, + cell.c, + cell.bold, + &font_regular, + &font_bold, + ); if use_fallback { paint_missing_symbol_fallback(&painter, cell_rect, cell.c, cell.fg); diff --git a/crates/rmux-terminal/src/state.rs b/crates/rmux-terminal/src/state.rs index 8cee7bf..2fe112a 100644 --- a/crates/rmux-terminal/src/state.rs +++ b/crates/rmux-terminal/src/state.rs @@ -166,12 +166,28 @@ impl TermState { /// which can be safely used for rendering without holding a borrow /// on the terminal state. pub fn snapshot(&self) -> GridSnapshot { + let mut out = GridSnapshot::default(); + self.snapshot_into(&mut out); + out + } + + /// Snapshot the visible grid into an existing [`GridSnapshot`]. + /// + /// Identical to [`Self::snapshot`] but reuses `out`'s row buffers instead of + /// allocating `rows` fresh `Vec`s every frame. A 200×50 grid is 10k cells, + /// so the allocating form churned a few hundred KB per pane per frame. + pub fn snapshot_into(&self, out: &mut GridSnapshot) { let cols = self.term.columns() as u16; let rows = self.term.screen_lines() as u16; let display_offset = self.term.grid().display_offset(); - let mut cells: Vec> = Vec::with_capacity(rows as usize); - for _ in 0..rows { + let cells = &mut out.cells; + cells.truncate(rows as usize); + for row in cells.iter_mut() { + row.clear(); + row.resize(cols as usize, GridCell::default()); + } + while cells.len() < rows as usize { cells.push(vec![GridCell::default(); cols as usize]); } @@ -239,20 +255,15 @@ impl TermState { cursor_point.line.0.max(0) as u16 }; - let cursor_col = cursor_point.column.0 as u16; - - GridSnapshot { - cols, - rows, - cells, - cursor_row, - cursor_col, - cursor_shape, - display_offset, - terminal_bg, - terminal_fg, - cursor_color, - } + out.cols = cols; + out.rows = rows; + out.cursor_row = cursor_row; + out.cursor_col = cursor_point.column.0 as u16; + out.cursor_shape = cursor_shape; + out.display_offset = display_offset; + out.terminal_bg = terminal_bg; + out.terminal_fg = terminal_fg; + out.cursor_color = cursor_color; } /// Resize the terminal to new dimensions. @@ -564,6 +575,23 @@ fn default_named_color(named: NamedColor, theme: &TerminalTheme) -> Rgb { } } +impl Default for GridSnapshot { + fn default() -> Self { + Self { + cols: 0, + rows: 0, + cells: Vec::new(), + cursor_row: 0, + cursor_col: 0, + cursor_shape: CursorShape::Block, + display_offset: 0, + terminal_bg: Color32::BLACK, + terminal_fg: Color32::WHITE, + cursor_color: Color32::WHITE, + } + } +} + impl Default for GridCell { fn default() -> Self { Self { @@ -608,6 +636,49 @@ mod tests { assert_eq!(snapshot.cells[0][4].c, 'o'); } + #[test] + fn test_snapshot_into_reuses_buffer_across_resizes() { + let mut state = TermState::new(80, 24, 1000); + state.feed_bytes(b"Hello"); + let mut buf = GridSnapshot::default(); + state.snapshot_into(&mut buf); + assert_eq!((buf.cols, buf.rows), (80, 24)); + assert_eq!(buf.cells[0][0].c, 'H'); + + // Shrink: stale rows/cols must be dropped, not left behind. + state.resize(20, 5); + state.snapshot_into(&mut buf); + assert_eq!((buf.cols, buf.rows), (20, 5)); + assert_eq!(buf.cells.len(), 5); + assert!(buf.cells.iter().all(|r| r.len() == 20)); + + // Grow again: rows are re-added and old content does not leak. + state.resize(100, 30); + state.snapshot_into(&mut buf); + assert_eq!((buf.cols, buf.rows), (100, 30)); + assert_eq!(buf.cells.len(), 30); + assert!(buf.cells.iter().all(|r| r.len() == 100)); + assert!(buf.cells[29].iter().all(|c| c.c == ' '), "grown rows must start blank"); + } + + #[test] + fn test_snapshot_matches_snapshot_into() { + let mut state = TermState::new(40, 10, 500); + state.feed_bytes(b"abc\r\ndef"); + let owned = state.snapshot(); + let mut buf = GridSnapshot::default(); + state.snapshot_into(&mut buf); + assert_eq!(owned.cols, buf.cols); + assert_eq!(owned.rows, buf.rows); + assert_eq!(owned.cursor_row, buf.cursor_row); + assert_eq!(owned.cursor_col, buf.cursor_col); + for (a, b) in owned.cells.iter().zip(buf.cells.iter()) { + let a: Vec = a.iter().map(|c| c.c).collect(); + let b: Vec = b.iter().map(|c| c.c).collect(); + assert_eq!(a, b); + } + } + #[test] fn test_resize_terminal() { let mut state = TermState::new(80, 24, 1000);