diff --git a/CHANGELOG.md b/CHANGELOG.md index 2efb079..3c94bba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Fixed 100% CPU usage caused by full-screen agent TUIs (notably OpenCode) that repaint continuously: session output now triggers a redraw only when a visible session's screen actually changes, and the partial-line heartbeat only when a session's inferred state changes. - Added startup profiles and live profile saving. - Added split panes with independent focus and PTY sizing. - Added a fuzzy command palette and session-name completion. diff --git a/src/app.rs b/src/app.rs index 7494f0b..3471c9c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -633,6 +633,17 @@ impl App { self.sessions.iter().filter(|s| !s.hidden).count() } + /// Whether the given session's screen is currently rendered in a pane. + /// Screen-changing output for an off-screen session needs no redraw — its + /// vt100 buffer is updated regardless, and any state change routes through + /// its own event. `panes` holds indices into `sessions`, not session ids. + fn session_is_visible(&self, session_id: usize) -> bool { + let Some(idx) = self.sessions.iter().position(|s| s.id == session_id) else { + return false; + }; + self.panes.contains(&Some(idx)) + } + pub fn apply_profile(&mut self, profile: &config::Profile) -> anyhow::Result<()> { let mut ids = HashMap::new(); for profile_session in &profile.sessions { @@ -1234,14 +1245,20 @@ impl App { self.handle_pane_resize([(rows, cols), self.pane_sizes[1]]); } - pub fn handle_session_bytes(&mut self, session_id: usize, data: Vec) { + /// Returns whether this output warrants a redraw: only when the byte chunk + /// actually changed a *visible* session's screen. Full-screen TUIs (opencode + /// especially) repaint continuously with byte-identical frames — gating on + /// real change keeps the 60 fps render loop off the CPU while idle. + pub fn handle_session_bytes(&mut self, session_id: usize, data: Vec) -> bool { + let mut changed = false; if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { - session.process_bytes(&data); + changed = session.process_bytes(&data); } // While the user is scrolled up, hold their position (tmux-style) // instead of yanking to the tail on every output burst — full-screen // TUIs redraw constantly, which previously made scrollback unusable // for them. Typing returns to the live view (see clear_scroll). + changed && self.session_is_visible(session_id) } pub fn handle_session_output(&mut self, session_id: usize, line: String) { @@ -1591,7 +1608,12 @@ impl App { self.pipe_tasks.insert(key, handle); } - pub fn handle_session_current_line(&mut self, session_id: usize, text: String) { + /// Returns whether the state changed. The partial-line text itself is not + /// displayed from here — the vt100 screen (fed via `SessionBytes`) already + /// carries it — so a redraw is only warranted when state inference flips + /// the session. This event fires every ~20ms per session as a heartbeat, so + /// gating on real change is what keeps idle sessions off the render loop. + pub fn handle_session_current_line(&mut self, session_id: usize, text: String) -> bool { let mut state_before = None; let mut state_after = None; @@ -1625,8 +1647,10 @@ impl App { if let (Some(before), Some(after)) = (state_before, state_after) { if before != after { self.on_state_transition(session_id, &before, &after); + return true; } } + false } pub fn handle_session_stats(&mut self, session_id: usize, stats: crate::session::TokenStats) { diff --git a/src/main.rs b/src/main.rs index 4265bfa..8e7e147 100644 --- a/src/main.rs +++ b/src/main.rs @@ -463,13 +463,24 @@ fn handle_event(app: &mut App, event: AppEvent) { } AppEvent::Tick => app.handle_tick(), AppEvent::SessionBytes { session_id, data } => { - app.handle_session_bytes(session_id, data); + // High-frequency path: full-screen TUIs stream bytes continuously. + // Only flag a redraw when a visible session's frame actually + // changed, and return early to skip the blanket needs_redraw below. + if app.handle_session_bytes(session_id, data) { + app.needs_redraw = true; + } + return; } AppEvent::SessionOutput { session_id, line } => { app.handle_session_output(session_id, line); } AppEvent::SessionCurrentLine { session_id, text } => { - app.handle_session_current_line(session_id, text); + // ~20ms-per-session heartbeat: only a state change warrants a + // redraw. Skip the blanket needs_redraw below. + if app.handle_session_current_line(session_id, text) { + app.needs_redraw = true; + } + return; } AppEvent::SessionWriter { session_id, diff --git a/src/session.rs b/src/session.rs index 3d242af..7f4428e 100644 --- a/src/session.rs +++ b/src/session.rs @@ -287,6 +287,11 @@ pub struct Session { pub stats_from_watcher: bool, /// Optional path to log session output lines to disk. pub log_path: Option, + /// Hash of the last rendered screen contents, used by `process_bytes` to + /// tell whether a byte chunk actually changed the visible frame. Full-screen + /// TUIs (opencode especially) repaint continuously with byte-identical + /// frames; suppressing redraws for those keeps the render loop off the CPU. + last_screen_hash: u64, } impl Session { @@ -346,12 +351,25 @@ impl Session { base, log_path: None, stats_from_watcher: false, + last_screen_hash: 0, } } - pub fn process_bytes(&mut self, data: &[u8]) { + /// Feed raw PTY bytes into the vt100 screen. Returns `true` if the visible + /// frame changed as a result — callers use this to skip redundant redraws + /// for full-screen TUIs that repaint with byte-identical output. + pub fn process_bytes(&mut self, data: &[u8]) -> bool { + use std::hash::{Hash, Hasher}; self.bytes_since_last_tick += data.len(); self.screen.process(data); + // contents_formatted is exactly what the display path renders from, so + // an unchanged hash means the next frame would be pixel-identical. + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.screen.screen().contents_formatted().hash(&mut hasher); + let hash = hasher.finish(); + let changed = hash != self.last_screen_hash; + self.last_screen_hash = hash; + changed } pub fn resize_screen(&mut self, rows: u16, cols: u16) { @@ -717,6 +735,18 @@ mod tests { assert_eq!(s.screen.screen().contents().trim(), "hello"); } + #[test] + fn process_bytes_reports_screen_change_only_on_visible_difference() { + let mut s = session(SessionKind::Shell); + + // First paint changes the (blank) screen. + assert!(s.process_bytes(b"hello")); + // Re-emitting an identical frame (as full-screen TUIs do) is a no-op. + assert!(!s.process_bytes(b"\x1b[1;1Hhello")); + // Actual new content changes the frame again. + assert!(s.process_bytes(b" world")); + } + #[test] fn push_output_line_keeps_bounded_scrollback() { let mut s = session(SessionKind::Shell);