aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-21 18:00:59 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-21 18:00:59 +0300
commitbc410c4261f12377a2eb7ea08f4112a1a9574a77 (patch)
tree99fef6ff2406c9be4147e63b0145f89c612261ea /src
parentb1c9833b828d12e7e0196c5607ae63372998e25a (diff)
perf: reduce headless server render overhead
Diffstat (limited to 'src')
-rw-r--r--src/app/mod.rs2
-rw-r--r--src/app/runtime.rs13
-rw-r--r--src/ghostty/mod.rs13
-rw-r--r--src/pane/terminal.rs63
-rw-r--r--src/persist/restore.rs1
-rw-r--r--src/server/headless.rs112
-rw-r--r--src/workspace.rs17
7 files changed, 173 insertions, 48 deletions
diff --git a/src/app/mod.rs b/src/app/mod.rs
index a598100a..a3094ed8 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -25,6 +25,8 @@ use std::time::{Duration, Instant};
const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16);
pub(crate) const ANIMATION_INTERVAL: Duration = Duration::from_millis(16);
+pub(crate) const HEADLESS_ANIMATION_INTERVAL: Duration = Duration::from_millis(128);
+pub(crate) const HEADLESS_ANIMATION_TICK_STEP: u32 = 8;
const RESIZE_POLL_INTERVAL: Duration = Duration::from_millis(100);
const GIT_REMOTE_STATUS_REFRESH_INTERVAL: Duration = Duration::from_millis(1500);
const AUTO_UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(30 * 60);
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index b6ad806b..739a3670 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -1,4 +1,4 @@
-use std::time::Instant;
+use std::time::{Duration, Instant};
use crossterm::terminal;
@@ -171,9 +171,16 @@ impl App {
}
pub(crate) fn sync_animation_timer(&mut self, now: Instant) {
+ self.sync_animation_timer_with_interval(now, ANIMATION_INTERVAL);
+ }
+
+ pub(crate) fn sync_headless_animation_timer(&mut self, now: Instant) {
+ self.sync_animation_timer_with_interval(now, crate::app::HEADLESS_ANIMATION_INTERVAL);
+ }
+
+ fn sync_animation_timer_with_interval(&mut self, now: Instant, interval: Duration) {
if self.agent_panel_has_animation() {
- self.next_animation_tick
- .get_or_insert(now + ANIMATION_INTERVAL);
+ self.next_animation_tick.get_or_insert(now + interval);
} else {
self.next_animation_tick = None;
}
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index e236b36f..75e600b8 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -1121,10 +1121,17 @@ impl<'a> RowCellIter<'a> {
}
pub fn graphemes(&self) -> Result<Vec<u32>, Error> {
+ let mut out = Vec::new();
+ self.graphemes_into(&mut out)?;
+ Ok(out)
+ }
+
+ pub fn graphemes_into(&self, out: &mut Vec<u32>) -> Result<(), Error> {
let len = self.grapheme_len()? as usize;
- let mut out = vec![0u32; len];
+ out.clear();
+ out.resize(len, 0);
if len == 0 {
- return Ok(out);
+ return Ok(());
}
// SAFETY: out buffer is allocated for the grapheme count returned by the API.
unsafe {
@@ -1135,7 +1142,7 @@ impl<'a> RowCellIter<'a> {
)
.into_result()?;
}
- Ok(out)
+ Ok(())
}
}
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 28b4186e..de4781b4 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -569,6 +569,8 @@ impl GhosttyPaneTerminal {
Ok(rows) => rows,
Err(_) => return,
};
+ let mut grapheme_scratch = Vec::new();
+ let mut symbol_scratch = String::new();
let mut y = 0u16;
while y < area.height && rows.next() {
let mut cells = match rows.populate_cells(&mut row_cells) {
@@ -579,11 +581,22 @@ impl GhosttyPaneTerminal {
while x < area.width && cells.next() {
let wide = cells.wide().unwrap_or(crate::ghostty::CellWide::Narrow);
let style = ghostty_cell_style(&cells, default_fg, default_bg);
- let symbol = ghostty_buffer_symbol(&cells, wide)
- .unwrap_or_else(|_| ghostty_blank_symbol_for_width(wide).to_string());
+ let symbol = match ghostty_buffer_symbol_into(
+ &cells,
+ wide,
+ &mut grapheme_scratch,
+ &mut symbol_scratch,
+ ) {
+ Ok(symbol) => symbol,
+ Err(_) => {
+ symbol_scratch.clear();
+ symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide));
+ symbol_scratch.as_str()
+ }
+ };
let cell = &mut buf[(area.x + x, area.y + y)];
cell.reset();
- cell.set_symbol(&symbol);
+ cell.set_symbol(symbol);
cell.set_style(style);
x += 1;
}
@@ -739,6 +752,7 @@ pub(super) fn ghostty_blank_symbol_for_width(wide: crate::ghostty::CellWide) ->
}
}
+#[cfg(test)]
pub(super) fn ghostty_normalize_buffer_symbol(
symbol: &str,
wide: crate::ghostty::CellWide,
@@ -760,18 +774,47 @@ pub(super) fn ghostty_normalize_buffer_symbol(
ghostty_blank_symbol_for_width(wide).to_string()
}
-fn ghostty_buffer_symbol(
+fn ghostty_buffer_symbol_into<'a>(
cells: &crate::ghostty::RowCellIter<'_>,
wide: crate::ghostty::CellWide,
-) -> Result<String, crate::ghostty::Error> {
- let symbol = match wide {
- crate::ghostty::CellWide::SpacerTail => String::new(),
- crate::ghostty::CellWide::SpacerHead => " ".to_string(),
+ grapheme_scratch: &mut Vec<u32>,
+ symbol_scratch: &'a mut String,
+) -> Result<&'a str, crate::ghostty::Error> {
+ symbol_scratch.clear();
+ match wide {
+ crate::ghostty::CellWide::SpacerTail => {}
+ crate::ghostty::CellWide::SpacerHead => symbol_scratch.push(' '),
crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::Wide => {
- ghostty_cell_symbol(cells)?
+ cells.graphemes_into(grapheme_scratch)?;
+ if grapheme_scratch.is_empty() {
+ symbol_scratch.push(' ');
+ } else {
+ for &codepoint in grapheme_scratch.iter() {
+ if let Some(ch) = char::from_u32(codepoint) {
+ symbol_scratch.push(ch);
+ }
+ }
+ if symbol_scratch.is_empty() {
+ symbol_scratch.push(' ');
+ }
+ }
}
+ }
+
+ let expected_width = match wide {
+ crate::ghostty::CellWide::Wide => 2,
+ crate::ghostty::CellWide::Narrow | crate::ghostty::CellWide::SpacerHead => 1,
+ crate::ghostty::CellWide::SpacerTail => 0,
};
- Ok(ghostty_normalize_buffer_symbol(&symbol, wide))
+ let actual_width = symbol_scratch.width();
+ if actual_width != expected_width
+ && !(wide == crate::ghostty::CellWide::Narrow && actual_width == 2)
+ {
+ symbol_scratch.clear();
+ symbol_scratch.push_str(ghostty_blank_symbol_for_width(wide));
+ }
+
+ Ok(symbol_scratch.as_str())
}
fn ghostty_reset_cell(
diff --git a/src/persist/restore.rs b/src/persist/restore.rs
index eeef0af1..3eb5a4a3 100644
--- a/src/persist/restore.rs
+++ b/src/persist/restore.rs
@@ -83,6 +83,7 @@ fn restore_workspace(
.unwrap_or_else(crate::workspace::generate_workspace_id),
custom_name: snap.custom_name.clone(),
identity_cwd: snap.identity_cwd.clone(),
+ cached_git_branch: None,
cached_git_ahead_behind: None,
public_pane_numbers,
next_public_pane_number,
diff --git a/src/server/headless.rs b/src/server/headless.rs
index d120d21b..d08a0ad0 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -162,6 +162,8 @@ struct ClientConnection {
host_terminal_theme: crate::terminal_theme::TerminalTheme,
/// Monotonic activity stamp used to choose the fallback foreground client.
last_activity: u64,
+ /// Last frame sent to this client. Used to skip identical frame sends.
+ last_frame: Option<FrameData>,
/// Channel for sending framed ServerMessage data to the client writer thread.
writer: Option<std::sync::mpsc::Sender<Vec<u8>>>,
}
@@ -465,7 +467,7 @@ impl HeadlessServer {
needs_render = true;
}
- self.app.sync_animation_timer(now);
+ self.app.sync_headless_animation_timer(now);
// 7. Render virtually and stream frames.
if needs_render && self.app.can_render_now(now) {
@@ -1005,6 +1007,7 @@ impl HeadlessServer {
terminal_size: (cols, rows),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
last_activity,
+ last_frame: None,
writer: Some(writer),
},
);
@@ -1281,22 +1284,20 @@ impl HeadlessServer {
/// frames to all connected clients.
fn render_and_stream(&mut self) {
let foreground_client_id = self.foreground_client_id;
- let mut render_targets: Vec<(u64, (u16, u16), std::sync::mpsc::Sender<Vec<u8>>, bool)> =
- self.clients
- .iter()
- .filter_map(|(&client_id, client)| {
- client.writer.as_ref().map(|writer| {
- (
- client_id,
- client.terminal_size,
- writer.clone(),
- foreground_client_id == Some(client_id),
- )
- })
- })
- .collect();
+ let mut render_targets: Vec<(u64, (u16, u16), bool)> = self
+ .clients
+ .iter()
+ .filter(|(_, client)| client.writer.is_some())
+ .map(|(&client_id, client)| {
+ (
+ client_id,
+ client.terminal_size,
+ foreground_client_id == Some(client_id),
+ )
+ })
+ .collect();
- render_targets.sort_by_key(|(client_id, _, _, is_foreground)| (*is_foreground, *client_id));
+ render_targets.sort_by_key(|(client_id, _, is_foreground)| (*is_foreground, *client_id));
if render_targets.is_empty() {
let (cols, rows) = self.effective_size;
@@ -1310,10 +1311,23 @@ impl HeadlessServer {
}
let mut broken_clients: Vec<u64> = Vec::new();
- for (client_id, (cols, rows), writer, is_foreground) in render_targets {
+ for (client_id, (cols, rows), is_foreground) in render_targets {
let area = Rect::new(0, 0, cols, rows);
let (buffer, cursor) = render_virtual(&mut self.app.state, area, is_foreground);
- let message = ServerMessage::Frame(FrameData::from_ratatui_buffer(&buffer, cursor));
+ let frame = FrameData::from_ratatui_buffer(&buffer, cursor);
+
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ continue;
+ };
+ let Some(writer) = client.writer.as_ref().cloned() else {
+ continue;
+ };
+
+ if client.last_frame.as_ref() == Some(&frame) {
+ continue;
+ }
+
+ let message = ServerMessage::Frame(frame.clone());
let serialized = match Self::frame_server_message(&message) {
Ok(framed) => framed,
Err(err) => {
@@ -1326,7 +1340,10 @@ impl HeadlessServer {
if writer.send(serialized).is_err() {
debug!(client_id, "client writer channel closed, marking as broken");
broken_clients.push(client_id);
+ continue;
}
+
+ client.last_frame = Some(frame);
}
if !broken_clients.is_empty() {
@@ -1349,7 +1366,7 @@ impl HeadlessServer {
fn handle_scheduled_tasks_headless(&mut self, now: Instant) -> bool {
let mut changed = false;
- self.app.sync_animation_timer(now);
+ self.app.sync_headless_animation_timer(now);
// No resize polling needed — server has no terminal.
// Client resize messages drive size changes instead.
@@ -1379,8 +1396,12 @@ impl HeadlessServer {
.next_animation_tick
.is_some_and(|deadline| now >= deadline)
{
- self.app.state.spinner_tick = self.app.state.spinner_tick.wrapping_add(1);
- self.app.next_animation_tick = Some(now + app::ANIMATION_INTERVAL);
+ self.app.state.spinner_tick = self
+ .app
+ .state
+ .spinner_tick
+ .wrapping_add(app::HEADLESS_ANIMATION_TICK_STEP);
+ self.app.next_animation_tick = Some(now + app::HEADLESS_ANIMATION_INTERVAL);
changed = true;
}
@@ -1412,7 +1433,7 @@ impl HeadlessServer {
self.app.save_session_now();
}
- self.app.sync_animation_timer(now);
+ self.app.sync_headless_animation_timer(now);
changed
}
@@ -1832,7 +1853,7 @@ mod tests {
let app = crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default());
let dir = std::env::temp_dir().join(format!(
- "herdr-headless-test-{}-{}",
+ "hh-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1942,7 +1963,7 @@ mod tests {
#[test]
fn prepare_socket_path_removes_stale_socket() {
let dir = std::env::temp_dir().join(format!(
- "herdr-test-stale-{}-{}",
+ "hs-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1969,7 +1990,7 @@ mod tests {
#[test]
fn prepare_socket_path_rejects_live_socket() {
let dir = std::env::temp_dir().join(format!(
- "herdr-test-live-{}-{}",
+ "hl-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -2062,6 +2083,7 @@ mod tests {
}),
},
last_activity: 1,
+ last_frame: None,
writer: None,
},
);
@@ -2082,6 +2104,7 @@ mod tests {
}),
},
last_activity: 2,
+ last_frame: None,
writer: None,
},
);
@@ -2128,6 +2151,7 @@ mod tests {
terminal_size: (120, 40),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
last_activity: 1,
+ last_frame: None,
writer: Some(desktop_tx),
},
);
@@ -2137,6 +2161,7 @@ mod tests {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
last_activity: 2,
+ last_frame: None,
writer: Some(phone_tx),
},
);
@@ -2154,6 +2179,41 @@ mod tests {
}
#[test]
+ fn render_and_stream_skips_identical_frame_sends() {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+ server.app.state.mode = crate::app::Mode::Terminal;
+
+ let (client_tx, client_rx) = std::sync::mpsc::channel();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ last_frame: None,
+ writer: Some(client_tx),
+ },
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ server.render_and_stream();
+ let first = client_rx.recv_timeout(Duration::from_millis(100));
+ assert!(first.is_ok(), "expected first frame to be sent");
+
+ server.render_and_stream();
+ assert!(
+ client_rx.recv_timeout(Duration::from_millis(50)).is_err(),
+ "identical frame should not be sent twice"
+ );
+ }
+
+ #[test]
fn clipboard_write_targets_foreground_client_only() {
let mut server = test_headless_server();
let (background_tx, background_rx) = std::sync::mpsc::channel();
@@ -2165,6 +2225,7 @@ mod tests {
terminal_size: (120, 40),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
last_activity: 1,
+ last_frame: None,
writer: Some(background_tx),
},
);
@@ -2174,6 +2235,7 @@ mod tests {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
last_activity: 2,
+ last_frame: None,
writer: Some(foreground_tx),
},
);
diff --git a/src/workspace.rs b/src/workspace.rs
index b80e5773..6d0d2488 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -44,6 +44,8 @@ pub struct Workspace {
pub custom_name: Option<String>,
/// Fallback workspace identity source for tests, old snapshots, or missing runtimes.
pub identity_cwd: PathBuf,
+ /// Cached current git branch for the workspace repo.
+ pub(crate) cached_git_branch: Option<String>,
/// Cached ahead/behind counts for the workspace repo's current branch upstream.
pub(crate) cached_git_ahead_behind: Option<(usize, usize)>,
/// Stable-ish public pane numbers within this workspace.
@@ -98,7 +100,8 @@ impl Workspace {
Ok(Self {
id: generate_workspace_id(),
custom_name: None,
- identity_cwd: initial_cwd,
+ identity_cwd: initial_cwd.clone(),
+ cached_git_branch: git_branch(&initial_cwd),
cached_git_ahead_behind: None,
public_pane_numbers,
next_public_pane_number: 2,
@@ -318,8 +321,7 @@ impl Workspace {
}
pub fn branch(&self) -> Option<String> {
- self.resolved_identity_cwd()
- .and_then(|cwd| git_branch(&cwd))
+ self.cached_git_branch.clone()
}
pub fn git_ahead_behind(&self) -> Option<(usize, usize)> {
@@ -327,9 +329,9 @@ impl Workspace {
}
pub fn refresh_git_ahead_behind(&mut self) {
- self.cached_git_ahead_behind = self
- .resolved_identity_cwd()
- .and_then(|cwd| git_ahead_behind(&cwd));
+ let cwd = self.resolved_identity_cwd();
+ self.cached_git_branch = cwd.as_deref().and_then(git_branch);
+ self.cached_git_ahead_behind = cwd.as_deref().and_then(git_ahead_behind);
}
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
@@ -447,7 +449,8 @@ impl Workspace {
Self {
id: generate_workspace_id(),
custom_name: Some(name.to_string()),
- identity_cwd,
+ identity_cwd: identity_cwd.clone(),
+ cached_git_branch: git_branch(&identity_cwd),
cached_git_ahead_behind: None,
public_pane_numbers,
next_public_pane_number: 2,