Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 27 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<u8>) {
/// 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<u8>) -> 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) {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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) {
Expand Down
15 changes: 13 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
32 changes: 31 additions & 1 deletion src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::path::PathBuf>,
/// 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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down
Loading