aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/api.rs2
-rw-r--r--src/app/mod.rs30
-rw-r--r--src/app/terminal_titles.rs143
-rw-r--r--src/app/window_title.rs171
-rw-r--r--src/client/mod.rs28
-rw-r--r--src/config.rs4
-rw-r--r--src/config/model.rs4
-rw-r--r--src/config/window_title.rs205
-rw-r--r--src/main.rs8
-rw-r--r--src/pane.rs10
-rw-r--r--src/pane/osc.rs10
-rw-r--r--src/pane/terminal.rs32
-rw-r--r--src/render_signal.rs38
-rw-r--r--src/server/handoff.rs58
-rw-r--r--src/server/headless.rs447
-rw-r--r--src/terminal_effects.rs21
16 files changed, 1121 insertions, 90 deletions
diff --git a/src/app/api.rs b/src/app/api.rs
index 379369ae..18471f57 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -923,7 +923,7 @@ impl App {
&mut self,
request: crate::api::schema::Request,
) -> String {
- self.sync_terminal_titles();
+ self.sync_pending_terminal_titles();
use crate::api::schema::{
ErrorBody, ErrorResponse, Method, ResponseResult, SuccessResponse,
};
diff --git a/src/app/mod.rs b/src/app/mod.rs
index cca530ce..d3d79954 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -26,6 +26,7 @@ mod tab_bar_status;
mod terminal_targets;
mod terminal_titles;
mod theme_sync;
+mod window_title;
mod worktrees;
use std::collections::{HashMap, HashSet};
@@ -144,6 +145,8 @@ pub struct App {
tab_bar_datetimes: Vec<tab_bar_status::TabBarDatetimeRuntime>,
tab_bar_commands: Vec<tab_bar_status::TabBarCommandRuntime>,
next_tab_bar_datetime_refresh: Option<Instant>,
+ /// Parsed `ui.window_title` plus the hostname resolved when it was applied.
+ window_title_template: Option<(crate::config::WindowTitleTemplate, String)>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
pub(crate) input_leases: input::InputLeaseTable,
@@ -773,6 +776,7 @@ impl App {
tab_bar_datetimes: Vec::new(),
tab_bar_commands: Vec::new(),
next_tab_bar_datetime_refresh: None,
+ window_title_template: None,
selection_autoscroll_deadline: None,
selection_highlight_clear_deadline: None,
persist_pane_history: config.experimental.pane_history,
@@ -794,6 +798,7 @@ impl App {
prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()),
};
app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator);
+ app.configure_window_title(&config.ui.window_title);
app
}
@@ -928,6 +933,7 @@ impl App {
self.query_host_terminal_theme();
let mut needs_render = true;
+ let mut sent_window_title: Option<Option<String>> = None;
let mut host_mouse_capture_active = self.state.mouse_capture;
let mut host_keyboard_report_all_active = false;
@@ -936,11 +942,6 @@ impl App {
if self.render_dirty.is_pending() {
needs_render = true;
}
- let terminal_title_changed = self.sync_terminal_titles();
- if terminal_title_changed && self.terminal_title_sidebar_configured() {
- needs_render = true;
- }
-
// Drain a bounded internal-event batch for responsiveness. API handlers
// perform an exhaustive drain before reading pane/runtime state.
if self.drain_internal_events() {
@@ -1062,7 +1063,20 @@ impl App {
self.sync_host_keyboard_report_all(&mut host_keyboard_report_all_active)?;
if needs_render && self.can_render_now(now) {
- let _ = self.render_dirty.take();
+ let render_request = self.render_dirty.take();
+ self.sync_terminal_titles(&render_request.terminal_title_sources);
+ if self.window_title_configured() {
+ let title = self
+ .window_title()
+ .and_then(|title| crate::config::sanitize_window_title_text(&title));
+ if sent_window_title.as_ref() != Some(&title) {
+ crate::terminal_effects::write_window_title(
+ &mut std::io::stdout(),
+ title.as_deref(),
+ )?;
+ sent_window_title = Some(title);
+ }
+ }
let _sync_output = SyncOutputGuard::begin()?;
let kitty_graphics_enabled = self.state.kitty_graphics_enabled;
if self.full_redraw_pending {
@@ -1437,6 +1451,9 @@ impl App {
diagnostics.extend(crate::config::tab_bar_right_diagnostics(
&config.ui.tab_bar_right,
));
+ diagnostics.extend(crate::config::window_title_diagnostics(
+ &config.ui.window_title,
+ ));
self.state.default_sidebar_width = config.ui.sidebar_width;
if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
@@ -1480,6 +1497,7 @@ impl App {
&config.ui.tab_bar_right,
&config.ui.tab_bar_right_separator,
);
+ self.configure_window_title(&config.ui.window_title);
self.state.agent_panel_sort =
agent_panel_sort_from_config(config.ui.agent_panel_sort);
self.state.status_indicators = config.ui.status_indicators;
diff --git a/src/app/terminal_titles.rs b/src/app/terminal_titles.rs
index a371d2dc..0ed2a0f7 100644
--- a/src/app/terminal_titles.rs
+++ b/src/app/terminal_titles.rs
@@ -1,48 +1,69 @@
+use std::collections::HashSet;
+
use super::App;
+use crate::layout::PaneId;
+
+#[derive(Debug, Default, PartialEq, Eq)]
+pub(crate) struct TerminalTitleChanges {
+ pub(crate) raw_changed: bool,
+ pub(crate) stripped_changed: bool,
+}
impl App {
- pub(crate) fn terminal_title_sidebar_configured(&self) -> bool {
+ pub(crate) fn terminal_title_sidebar_changed(&self, changes: &TerminalTitleChanges) -> bool {
let config = &self.state.sidebar_agents;
std::iter::once(&config.rows)
.chain(config.rows_by_agent.values())
.flatten()
.flatten()
- .any(|token| {
- matches!(
- token.parts().0,
- crate::config::AgentSidebarToken::TerminalTitle
- | crate::config::AgentSidebarToken::TerminalTitleStripped
- )
+ .any(|token| match token.parts().0 {
+ crate::config::AgentSidebarToken::TerminalTitle => changes.raw_changed,
+ crate::config::AgentSidebarToken::TerminalTitleStripped => changes.stripped_changed,
+ _ => false,
})
}
- pub(crate) fn sync_terminal_titles(&mut self) -> bool {
- let mut observations = Vec::new();
- for (ws_idx, workspace) in self.state.workspaces.iter().enumerate() {
- for tab in &workspace.tabs {
- for (pane_id, pane) in &tab.panes {
- let terminal_id = &pane.attached_terminal_id;
- let Some(runtime) = self.terminal_runtimes.get(terminal_id) else {
- continue;
- };
- observations.push((
- ws_idx,
- *pane_id,
- terminal_id.clone(),
- runtime.terminal_title(),
- ));
- }
- }
+ pub(crate) fn sync_pending_terminal_titles(&mut self) -> TerminalTitleChanges {
+ let sources = self.render_dirty.pending_terminal_title_sources();
+ let changes = self.sync_terminal_titles(&sources);
+ if self.terminal_title_sidebar_changed(&changes) {
+ self.render_dirty.request_generic();
+ self.render_notify.notify_one();
+ }
+ changes
+ }
+
+ pub(crate) fn sync_terminal_titles(
+ &mut self,
+ sources: &HashSet<PaneId>,
+ ) -> TerminalTitleChanges {
+ if sources.is_empty() {
+ return TerminalTitleChanges::default();
+ }
+
+ let mut observations = Vec::with_capacity(sources.len());
+ for pane_id in sources {
+ let Some((ws_idx, terminal_id)) = self
+ .find_pane(*pane_id)
+ .map(|(ws_idx, pane)| (ws_idx, pane.attached_terminal_id.clone()))
+ else {
+ continue;
+ };
+ let Some(runtime) = self.terminal_runtimes.get(&terminal_id) else {
+ continue;
+ };
+ observations.push((ws_idx, *pane_id, terminal_id, runtime.terminal_title()));
}
- let mut raw_changed = false;
+ let mut changes = TerminalTitleChanges::default();
let mut publish = Vec::new();
for (ws_idx, pane_id, terminal_id, title) in observations {
let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
continue;
};
let change = terminal.set_terminal_title(title);
- raw_changed |= change.raw_changed;
+ changes.raw_changed |= change.raw_changed;
+ changes.stripped_changed |= change.stripped_changed;
if change.stripped_changed {
publish.push((ws_idx, pane_id));
}
@@ -52,7 +73,7 @@ impl App {
self.emit_pane_updated(ws_idx, pane_id);
}
- raw_changed
+ changes
}
}
@@ -69,6 +90,7 @@ mod tests {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub.clone());
app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.active = Some(0);
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
@@ -80,8 +102,15 @@ mod tests {
let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
runtime.test_process_pty_bytes("\x1b]0;⠋ 修复🙂标题\x07".as_bytes());
app.terminal_runtimes.insert(terminal_id.clone(), runtime);
+ let sources = HashSet::from([pane_id]);
- assert!(app.sync_terminal_titles());
+ assert_eq!(
+ app.sync_terminal_titles(&sources),
+ TerminalTitleChanges {
+ raw_changed: true,
+ stripped_changed: true,
+ }
+ );
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title.as_deref(), Some("⠋ 修复🙂标题"));
assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
@@ -96,7 +125,13 @@ mod tests {
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes("\x1b]2;⠙ 修复🙂标题\x1b\\".as_bytes());
- assert!(app.sync_terminal_titles());
+ assert_eq!(
+ app.sync_terminal_titles(&sources),
+ TerminalTitleChanges {
+ raw_changed: true,
+ stripped_changed: false,
+ }
+ );
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title.as_deref(), Some("⠙ 修复🙂标题"));
assert_eq!(pane.terminal_title_stripped.as_deref(), Some("修复🙂标题"));
@@ -107,14 +142,14 @@ mod tests {
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes(b"\x1b]0;Done reviewing\x07");
- assert!(app.sync_terminal_titles());
+ assert!(app.sync_terminal_titles(&sources).stripped_changed);
assert_eq!(pane_updated_events(&event_hub), 2);
app.terminal_runtimes
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes(b"\x1b]0;\x07");
- assert!(app.sync_terminal_titles());
+ assert!(app.sync_terminal_titles(&sources).stripped_changed);
let pane = app.pane_info(0, pane_id).unwrap();
assert_eq!(pane.terminal_title, None);
assert_eq!(pane.terminal_title_stripped, None);
@@ -122,8 +157,36 @@ mod tests {
assert_eq!(pane_updated_events(&event_hub), 3);
}
+ #[tokio::test]
+ async fn syncing_pending_titles_preserves_sidebar_render_impact() {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
+ app.state.workspaces = vec![Workspace::test_new("one")];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app.state.sidebar_agents.rows = vec![vec![
+ crate::config::AgentSidebarToken::TerminalTitleStripped,
+ ]];
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0]
+ .terminal_id(pane_id)
+ .unwrap()
+ .clone();
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes(b"\x1b]0;building\x07");
+ app.terminal_runtimes.insert(terminal_id, runtime);
+ app.render_dirty.request_terminal_title(pane_id);
+
+ let changes = app.sync_pending_terminal_titles();
+
+ assert!(changes.stripped_changed);
+ let render_request = app.render_dirty.take();
+ assert!(render_request.generic);
+ }
+
#[test]
- fn override_only_terminal_title_token_requests_sidebar_redraws() {
+ fn sidebar_redraws_only_for_the_configured_title_form() {
let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
@@ -135,7 +198,21 @@ mod tests {
]],
);
- assert!(app.terminal_title_sidebar_configured());
+ let spinner_only = TerminalTitleChanges {
+ raw_changed: true,
+ ..TerminalTitleChanges::default()
+ };
+ assert!(!app.terminal_title_sidebar_changed(&spinner_only));
+ assert!(app.terminal_title_sidebar_changed(&TerminalTitleChanges {
+ stripped_changed: true,
+ ..TerminalTitleChanges::default()
+ }));
+
+ app.state.sidebar_agents.rows_by_agent.insert(
+ "claude".into(),
+ vec![vec![crate::config::AgentSidebarToken::TerminalTitle]],
+ );
+ assert!(app.terminal_title_sidebar_changed(&spinner_only));
}
fn pane_updated_events(event_hub: &crate::api::EventHub) -> usize {
diff --git a/src/app/window_title.rs b/src/app/window_title.rs
new file mode 100644
index 00000000..841c6a92
--- /dev/null
+++ b/src/app/window_title.rs
@@ -0,0 +1,171 @@
+//! Outer terminal window title.
+//!
+//! Herdr is a terminal emulator, so `OSC 0`/`OSC 2` written by a pane stops at
+//! Herdr and never reaches the terminal Herdr itself runs in. Without this the
+//! host window title keeps whatever the shell or `ssh` left behind, which is
+//! what window managers show in tab and group bars.
+//!
+//! The title is rendered on the server so `{hostname}` names the host the panes
+//! actually live on, not the machine a thin remote client runs on. The server
+//! pushes the result to the foreground client, which writes the `OSC 0`.
+
+use super::App;
+use crate::config::{WindowTitlePart, WindowTitleTemplate, WindowTitleToken};
+
+impl App {
+ pub(crate) fn configure_window_title(&mut self, template: &str) {
+ self.window_title_template =
+ WindowTitleTemplate::parse(template)
+ .ok()
+ .flatten()
+ .map(|template| {
+ // Resolve the hostname once here rather than per render.
+ let hostname = if template.uses(WindowTitleToken::Hostname) {
+ crate::platform::hostname().unwrap_or_default()
+ } else {
+ String::new()
+ };
+ (template, hostname)
+ });
+ }
+
+ /// Whether `ui.window_title` asks Herdr to own the outer terminal title at
+ /// all. When it does not, Herdr leaves whatever the shell or `ssh` set.
+ pub(crate) fn window_title_configured(&self) -> bool {
+ self.window_title_template.is_some()
+ }
+
+ /// Whether the title depends on the focused pane's own terminal title, which
+ /// is the one input that arrives through PTY parsing rather than app state.
+ pub(crate) fn window_title_uses_terminal_title(&self) -> bool {
+ self.window_title_template
+ .as_ref()
+ .is_some_and(|(template, _)| template.uses(WindowTitleToken::TerminalTitle))
+ }
+
+ /// Renders the configured outer window title, or `None` when window titles
+ /// are disabled or every token resolved empty.
+ pub(crate) fn window_title(&self) -> Option<String> {
+ let (template, hostname) = self.window_title_template.as_ref()?;
+ let workspace = self
+ .state
+ .active
+ .and_then(|ws_idx| self.state.workspaces.get(ws_idx));
+
+ let mut title = String::new();
+ for part in template.parts() {
+ match part {
+ WindowTitlePart::Literal(literal) => title.push_str(literal),
+ WindowTitlePart::Token(WindowTitleToken::Hostname) => title.push_str(hostname),
+ WindowTitlePart::Token(WindowTitleToken::Workspace) => {
+ if let Some(workspace) = workspace {
+ title.push_str(
+ &workspace.display_name_from_terminals(&self.state.terminals),
+ );
+ }
+ }
+ WindowTitlePart::Token(WindowTitleToken::Tab) => {
+ if let Some(name) = workspace.and_then(|ws| ws.active_tab_display_name()) {
+ title.push_str(&name);
+ }
+ }
+ WindowTitlePart::Token(WindowTitleToken::Pane) => {
+ if let Some(label) = self
+ .focused_terminal_state()
+ .and_then(|terminal| terminal.manual_label.as_deref())
+ {
+ title.push_str(label);
+ }
+ }
+ WindowTitlePart::Token(WindowTitleToken::TerminalTitle) => {
+ if let Some(terminal_title) = self
+ .focused_terminal_state()
+ .and_then(|terminal| terminal.terminal_title_stripped())
+ {
+ title.push_str(&terminal_title);
+ }
+ }
+ }
+ }
+
+ Some(title)
+ }
+
+ fn focused_terminal_state(&self) -> Option<&crate::terminal::TerminalState> {
+ let workspace = self.state.workspaces.get(self.state.active?)?;
+ let terminal_id = workspace.terminal_id(workspace.focused_pane_id()?)?;
+ self.state.terminals.get(terminal_id)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use crate::app::App;
+ use crate::config::Config;
+ use crate::workspace::Workspace;
+
+ fn test_app() -> App {
+ let event_hub = crate::api::EventHub::default();
+ let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut app = App::new(&Config::default(), true, None, api_rx, event_hub);
+ app.state.workspaces = vec![Workspace::test_new("herd")];
+ app.state.active = Some(0);
+ app.state.ensure_test_terminals();
+ app
+ }
+
+ #[test]
+ fn renders_workspace_and_tab_names() {
+ let mut app = test_app();
+ app.configure_window_title("{workspace}/{tab}");
+
+ assert_eq!(app.window_title().as_deref(), Some("herd/1"));
+
+ app.state.workspaces[0].tabs[0].custom_name = Some("build".into());
+ assert_eq!(app.window_title().as_deref(), Some("herd/build"));
+ }
+
+ #[test]
+ fn renders_focused_pane_label_and_terminal_title() {
+ let mut app = test_app();
+ app.configure_window_title("{pane}|{terminal_title}");
+
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
+ .attached_terminal_id
+ .clone();
+ let terminal = app
+ .state
+ .terminals
+ .get_mut(&terminal_id)
+ .expect("focused terminal");
+ terminal.manual_label = Some("api".into());
+ terminal.set_terminal_title(Some("⠋ building".into()));
+
+ assert_eq!(app.window_title().as_deref(), Some("api|building"));
+ }
+
+ #[test]
+ fn empty_template_disables_window_titles() {
+ let mut app = test_app();
+ app.configure_window_title("");
+
+ assert_eq!(app.window_title(), None);
+ }
+
+ #[test]
+ fn invalid_template_disables_window_titles() {
+ let mut app = test_app();
+ app.configure_window_title("{nope}");
+
+ assert_eq!(app.window_title(), None);
+ }
+
+ #[test]
+ fn unset_tokens_render_empty() {
+ let mut app = test_app();
+ app.configure_window_title("[{pane}]");
+
+ assert_eq!(app.window_title().as_deref(), Some("[]"));
+ }
+}
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 0d28b47a..e19c47ca 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -1808,8 +1808,10 @@ async fn run_client_loop(
let _ = io::stdout().flush();
}
ServerMessage::WindowTitle { title } => {
- write_window_title(title.as_deref());
- let _ = io::stdout().flush();
+ let _ = crate::terminal_effects::write_window_title(
+ &mut io::stdout(),
+ title.as_deref(),
+ );
}
ServerMessage::ReloadSoundConfig => {
reload_local_client_config(
@@ -2302,19 +2304,6 @@ fn forward_clipboard(data: &str) {
crate::selection::write_osc52_bytes(&bytes);
}
-fn window_title_osc(title: Option<&str>) -> Vec<u8> {
- let title = title.unwrap_or("herdr");
- let safe_title = title
- .chars()
- .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}'))
- .collect::<String>();
- format!("\x1b]0;{safe_title}\x07").into_bytes()
-}
-
-fn write_window_title(title: Option<&str>) {
- let _ = io::stdout().write_all(&window_title_osc(title));
-}
-
// ---------------------------------------------------------------------------
// Frame output
// ---------------------------------------------------------------------------
@@ -3569,13 +3558,4 @@ mod tests {
std::env::remove_var("SSH_CONNECTION");
}
}
-
- #[test]
- fn window_title_osc_strips_terminators_and_defaults_to_herdr() {
- assert_eq!(
- window_title_osc(Some("herdr\x1b api\u{7}\u{9c}")),
- b"\x1b]0;herdr api\x07"
- );
- assert_eq!(window_title_osc(None), b"\x1b]0;herdr\x07");
- }
}
diff --git a/src/config.rs b/src/config.rs
index 88645061..7e7aecd2 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -7,6 +7,7 @@ mod sidebar;
mod sound;
mod tab_bar;
mod theme;
+mod window_title;
pub use self::{
io::{
@@ -33,6 +34,7 @@ pub use self::{
sound::SoundConfig,
tab_bar::TabBarRightEntryConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig, THEME_NAMES},
+ window_title::{WindowTitlePart, WindowTitleTemplate, WindowTitleToken},
};
pub(crate) use self::keybinds::parse_key_combo;
@@ -44,6 +46,7 @@ pub(crate) use self::{
MAX_TAB_BAR_RIGHT_ENTRIES,
},
theme::canonical_theme_name,
+ window_title::{sanitize_window_title_text, window_title_diagnostics},
};
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
@@ -85,6 +88,7 @@ impl Config {
.chain(self.theme.diagnostics())
.chain(self.ui.sound.diagnostics())
.chain(tab_bar_right_diagnostics(&self.ui.tab_bar_right))
+ .chain(window_title_diagnostics(&self.ui.window_title))
.chain(self.invalid_sidebar_bounds_diagnostic())
.collect()
}
diff --git a/src/config/model.rs b/src/config/model.rs
index 0b237376..9b5904fb 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -890,6 +890,9 @@ pub struct UiConfig {
pub tab_bar_right: Vec<TabBarRightEntryConfig>,
/// Text inserted between visible right-side tab bar entries. Default: one space.
pub tab_bar_right_separator: String,
+ /// Format for the outer terminal window title. Empty leaves the title alone.
+ /// Default: "{hostname}: {workspace}".
+ pub window_title: String,
/// Agent sidebar ordering. Saved values are "spaces" or "priority". Default: "spaces".
pub agent_panel_sort: AgentPanelSortConfig,
/// Retired setting that Herdr wrote before the workspace filter was removed.
@@ -1108,6 +1111,7 @@ impl Default for UiConfig {
tab_bar_position: TabBarPositionConfig::Top,
tab_bar_right: Vec::new(),
tab_bar_right_separator: " ".into(),
+ window_title: super::window_title::default_window_title(),
agent_panel_sort: AgentPanelSortConfig::Spaces,
_legacy_agent_panel_scope: None,
status_indicators: StatusIndicatorStyle::Dots,
diff --git a/src/config/window_title.rs b/src/config/window_title.rs
new file mode 100644
index 00000000..d7598f51
--- /dev/null
+++ b/src/config/window_title.rs
@@ -0,0 +1,205 @@
+/// Maximum characters in a rendered outer terminal window title.
+pub(crate) const MAX_WINDOW_TITLE_CHARS: usize = 200;
+
+pub(crate) fn default_window_title() -> String {
+ "{hostname}: {workspace}".to_string()
+}
+
+pub(crate) fn sanitize_window_title_text(value: &str) -> Option<String> {
+ let sanitized = value
+ .chars()
+ .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control())
+ .take(MAX_WINDOW_TITLE_CHARS)
+ .collect::<String>()
+ .trim()
+ .to_string();
+ (!sanitized.is_empty()).then_some(sanitized)
+}
+
+/// A value the server can substitute into `ui.window_title`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WindowTitleToken {
+ /// Host the Herdr server runs on.
+ Hostname,
+ /// Active workspace display name.
+ Workspace,
+ /// Active tab display name.
+ Tab,
+ /// Manual name of the focused pane.
+ Pane,
+ /// Focused pane's terminal title, with spinner frames stripped.
+ TerminalTitle,
+}
+
+impl WindowTitleToken {
+ fn parse(name: &str) -> Option<Self> {
+ match name {
+ "hostname" => Some(Self::Hostname),
+ "workspace" => Some(Self::Workspace),
+ "tab" => Some(Self::Tab),
+ "pane" => Some(Self::Pane),
+ "terminal_title" => Some(Self::TerminalTitle),
+ _ => None,
+ }
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum WindowTitlePart {
+ Literal(String),
+ Token(WindowTitleToken),
+}
+
+/// Parsed `ui.window_title` format string.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WindowTitleTemplate {
+ parts: Vec<WindowTitlePart>,
+}
+
+impl WindowTitleTemplate {
+ /// Parses a window title format. An empty format disables outer window
+ /// titles and parses to `Ok(None)`.
+ pub fn parse(template: &str) -> Result<Option<Self>, String> {
+ if template.is_empty() {
+ return Ok(None);
+ }
+
+ let mut parts = Vec::new();
+ let mut literal = String::new();
+ let mut chars = template.chars().peekable();
+ while let Some(ch) = chars.next() {
+ match ch {
+ '{' if chars.peek() == Some(&'{') => {
+ chars.next();
+ literal.push('{');
+ }
+ '}' if chars.peek() == Some(&'}') => {
+ chars.next();
+ literal.push('}');
+ }
+ '{' => {
+ let mut name = String::new();
+ let mut closed = false;
+ for ch in chars.by_ref() {
+ if ch == '}' {
+ closed = true;
+ break;
+ }
+ name.push(ch);
+ }
+ if !closed {
+ return Err("has an unclosed '{'".to_string());
+ }
+ let Some(token) = WindowTitleToken::parse(name.trim()) else {
+ return Err(format!("has unknown token '{{{name}}}'"));
+ };
+ if !literal.is_empty() {
+ parts.push(WindowTitlePart::Literal(std::mem::take(&mut literal)));
+ }
+ parts.push(WindowTitlePart::Token(token));
+ }
+ '}' => return Err("has an unmatched '}'".to_string()),
+ _ => literal.push(ch),
+ }
+ }
+ if !literal.is_empty() {
+ parts.push(WindowTitlePart::Literal(literal));
+ }
+
+ Ok((!parts.is_empty()).then_some(Self { parts }))
+ }
+
+ pub fn parts(&self) -> &[WindowTitlePart] {
+ &self.parts
+ }
+
+ pub fn uses(&self, token: WindowTitleToken) -> bool {
+ self.parts
+ .iter()
+ .any(|part| matches!(part, WindowTitlePart::Token(other) if *other == token))
+ }
+}
+
+pub(crate) fn window_title_diagnostics(template: &str) -> Option<String> {
+ WindowTitleTemplate::parse(template)
+ .err()
+ .map(|err| format!("ui.window_title {err}; leaving the outer terminal title alone"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_tokens_literals_and_escapes() {
+ let template = WindowTitleTemplate::parse("{hostname}: {workspace} {{a}}")
+ .expect("parse")
+ .expect("template");
+
+ assert_eq!(
+ template.parts(),
+ [
+ WindowTitlePart::Token(WindowTitleToken::Hostname),
+ WindowTitlePart::Literal(": ".into()),
+ WindowTitlePart::Token(WindowTitleToken::Workspace),
+ WindowTitlePart::Literal(" {a}".into()),
+ ]
+ );
+ assert!(template.uses(WindowTitleToken::Hostname));
+ assert!(!template.uses(WindowTitleToken::TerminalTitle));
+ }
+
+ #[test]
+ fn default_template_parses() {
+ assert!(WindowTitleTemplate::parse(&default_window_title())
+ .expect("parse")
+ .is_some());
+ }
+
+ #[test]
+ fn empty_template_disables_window_titles() {
+ assert_eq!(WindowTitleTemplate::parse(""), Ok(None));
+ assert!(window_title_diagnostics("").is_none());
+ }
+
+ #[test]
+ fn invalid_templates_report_diagnostics() {
+ assert!(window_title_diagnostics("{hostname")
+ .expect("diagnostic")
+ .contains("unclosed"));
+ assert!(window_title_diagnostics("a } b")
+ .expect("diagnostic")
+ .contains("unmatched"));
+ assert!(window_title_diagnostics("{session}")
+ .expect("diagnostic")
+ .contains("unknown token '{session}'"));
+ }
+
+ #[test]
+ fn sanitizes_and_bounds_rendered_titles() {
+ assert_eq!(
+ sanitize_window_title_text(" herdr\u{1b} api\u{7}\n ").as_deref(),
+ Some("herdr api")
+ );
+ assert_eq!(sanitize_window_title_text("\u{7}\n"), None);
+ assert_eq!(
+ sanitize_window_title_text(&"x".repeat(MAX_WINDOW_TITLE_CHARS + 1))
+ .expect("title")
+ .chars()
+ .count(),
+ MAX_WINDOW_TITLE_CHARS
+ );
+ }
+
+ #[test]
+ fn token_names_tolerate_surrounding_whitespace() {
+ let template = WindowTitleTemplate::parse("{ tab }")
+ .expect("parse")
+ .expect("template");
+
+ assert_eq!(
+ template.parts(),
+ [WindowTitlePart::Token(WindowTitleToken::Tab)]
+ );
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index c32cfe53..4d915158 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -340,6 +340,14 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# tab_bar_right = []
# tab_bar_right_separator = " "
+# Title Herdr writes to the terminal it runs in, which is what window managers
+# show in title, tab, and group bars. Tokens are {hostname}, {workspace}, {tab},
+# {pane}, and {terminal_title}; {{ and }} are literal braces.
+# The title renders on the Herdr server, so {hostname} names the host the panes
+# run on even when attaching from a remote client.
+# Set to "" to leave the outer terminal title alone.
+# window_title = "{hostname}: {workspace}"
+
# Agent panel ordering: "spaces" (grouped by space) or "priority" (attention queue).
# "workspaces" is accepted as an alias for "spaces".
# agent_panel_sort = "spaces"
diff --git a/src/pane.rs b/src/pane.rs
index 130c75e5..1ade096e 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1899,7 +1899,10 @@ impl PaneRuntime {
terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
publish_terminal_bells(pane_id, result.terminal_bells, &read_events);
observe_detection_content_change(bytes, &detection_content_seq);
- if result.request_render && render_dirty.request_pty(pane_id) {
+ let title_requested =
+ result.terminal_title_changed && render_dirty.request_terminal_title(pane_id);
+ let render_requested = result.request_render && render_dirty.request_pty(pane_id);
+ if title_requested || render_requested {
render_notify.notify_one();
}
if let Some(delay) = result.render_delay {
@@ -2062,7 +2065,10 @@ impl PaneRuntime {
if agent_detection == AgentDetection::Enabled {
observe_detection_content_change(bytes, &detection_content_seq);
}
- if result.request_render && render_dirty.request_pty(pane_id) {
+ let title_requested =
+ result.terminal_title_changed && render_dirty.request_terminal_title(pane_id);
+ let render_requested = result.request_render && render_dirty.request_pty(pane_id);
+ if title_requested || render_requested {
render_notify.notify_one();
}
if let Some(delay) = result.render_delay {
diff --git a/src/pane/osc.rs b/src/pane/osc.rs
index 16ee8fee..a281af77 100644
--- a/src/pane/osc.rs
+++ b/src/pane/osc.rs
@@ -464,13 +464,14 @@ pub(super) struct AgentOscStateTracker {
}
impl AgentOscStateTracker {
- pub(super) fn observe(&mut self, bytes: &[u8]) {
+ pub(super) fn observe(&mut self, bytes: &[u8]) -> bool {
let (collector, latest_title, terminal_title, latest_progress) = (
&mut self.collector,
&mut self.latest_title,
&mut self.terminal_title,
&mut self.latest_progress,
);
+ let mut terminal_title_changed = false;
collector.observe(bytes, |body| {
let Some((command, payload)) = parse_agent_osc_body(body) else {
return;
@@ -478,8 +479,10 @@ impl AgentOscStateTracker {
match command {
b"0" | b"2" => {
let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS);
- *terminal_title = (!title.is_empty()).then_some(title.clone());
- *latest_title = (!title.is_empty()).then_some(title);
+ let title = (!title.is_empty()).then_some(title);
+ terminal_title_changed |= *terminal_title != title;
+ *terminal_title = title.clone();
+ *latest_title = title;
}
b"9" => {
*latest_progress =
@@ -488,6 +491,7 @@ impl AgentOscStateTracker {
_ => {}
}
});
+ terminal_title_changed
}
pub(super) fn terminal_title(&self) -> Option<&str> {
diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs
index 9575ad6f..3524df21 100644
--- a/src/pane/terminal.rs
+++ b/src/pane/terminal.rs
@@ -146,6 +146,7 @@ impl InputState {
pub(crate) struct ProcessBytesResult {
pub request_render: bool,
pub render_delay: Option<Duration>,
+ pub terminal_title_changed: bool,
pub terminal_bells: u16,
pub clipboard_writes: Vec<Vec<u8>>,
pub reported_cwd: Option<std::path::PathBuf>,
@@ -1201,6 +1202,7 @@ impl GhosttyPaneTerminal {
return ProcessBytesResult {
request_render: false,
render_delay: None,
+ terminal_title_changed: false,
terminal_bells: 0,
clipboard_writes: Vec::new(),
reported_cwd: None,
@@ -1233,7 +1235,7 @@ impl GhosttyPaneTerminal {
"agent OSC evidence observed"
);
}
- core.agent_osc_state.observe(bytes);
+ let terminal_title_changed = core.agent_osc_state.observe(bytes);
let alternate_screen = core
.terminal
@@ -1335,6 +1337,7 @@ impl GhosttyPaneTerminal {
ProcessBytesResult {
request_render,
render_delay,
+ terminal_title_changed,
terminal_bells,
clipboard_writes,
reported_cwd,
@@ -3777,6 +3780,33 @@ mod tests {
}
#[test]
+ fn process_pty_bytes_reports_only_completed_title_changes() {
+ let (tx, _rx) = mpsc::channel(4);
+ let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
+ let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
+ let pane_id = PaneId::from_raw(1);
+
+ assert!(
+ !pane
+ .process_pty_bytes(pane_id, 0, b"\x1b]0;buil", &tx)
+ .terminal_title_changed
+ );
+ assert!(
+ pane.process_pty_bytes(pane_id, 0, b"ding\x07", &tx)
+ .terminal_title_changed
+ );
+ assert!(
+ !pane
+ .process_pty_bytes(pane_id, 0, b"\x1b]2;building\x07", &tx)
+ .terminal_title_changed
+ );
+ assert!(
+ pane.process_pty_bytes(pane_id, 0, b"\x1b]2;done\x07", &tx)
+ .terminal_title_changed
+ );
+ }
+
+ #[test]
fn process_pty_bytes_surfaces_clipboard_writes_without_other_results() {
let (tx, _rx) = mpsc::channel(4);
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
diff --git a/src/render_signal.rs b/src/render_signal.rs
index d4544e5f..2f6d9cb8 100644
--- a/src/render_signal.rs
+++ b/src/render_signal.rs
@@ -8,6 +8,7 @@ use crate::layout::PaneId;
pub(crate) struct RenderRequest {
pub(crate) generic: bool,
pub(crate) pty_sources: HashSet<PaneId>,
+ pub(crate) terminal_title_sources: HashSet<PaneId>,
}
/// Coalesces render requests while retaining enough origin information for the
@@ -46,6 +47,25 @@ impl RenderSignal {
!self.pending.swap(true, Ordering::AcqRel)
}
+ /// Coalesces terminal-title changes separately from ordinary PTY damage so
+ /// consumers can update metadata without inspecting every pane.
+ pub(crate) fn request_terminal_title(&self, pane_id: PaneId) -> bool {
+ let mut request = self
+ .request
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner);
+ request.terminal_title_sources.insert(pane_id);
+ !self.pending.swap(true, Ordering::AcqRel)
+ }
+
+ pub(crate) fn pending_terminal_title_sources(&self) -> HashSet<PaneId> {
+ self.request
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ .terminal_title_sources
+ .clone()
+ }
+
pub(crate) fn take(&self) -> RenderRequest {
let mut request = self
.request
@@ -73,10 +93,28 @@ mod tests {
let request = signal.take();
assert!(!request.generic);
assert_eq!(request.pty_sources, HashSet::from([first, second]));
+ assert!(request.terminal_title_sources.is_empty());
assert!(!signal.is_pending());
}
#[test]
+ fn coalesces_terminal_title_sources_without_making_them_pty_damage() {
+ let signal = RenderSignal::new();
+ let pane_id = PaneId::from_raw(10);
+
+ assert!(signal.request_terminal_title(pane_id));
+ assert!(!signal.request_terminal_title(pane_id));
+ assert_eq!(
+ signal.pending_terminal_title_sources(),
+ HashSet::from([pane_id])
+ );
+
+ let request = signal.take();
+ assert!(request.pty_sources.is_empty());
+ assert_eq!(request.terminal_title_sources, HashSet::from([pane_id]));
+ }
+
+ #[test]
fn keeps_generic_and_pty_requests_distinct() {
let signal = RenderSignal::new();
let pane_id = PaneId::from_raw(10);
diff --git a/src/server/handoff.rs b/src/server/handoff.rs
index d17bfa91..2cd17144 100644
--- a/src/server/handoff.rs
+++ b/src/server/handoff.rs
@@ -39,6 +39,11 @@ pub(crate) struct HandoffManifest {
pub expected_protocol: Option<u32>,
pub snapshot: crate::persist::SessionSnapshot,
pub panes: Vec<crate::handoff_runtime::HandoffRuntimeState>,
+ /// An outer window title set over the API outlives the server that took the
+ /// call, so a handoff carries it rather than falling back to the config.
+ /// Absent from manifests written before this field existed.
+ #[serde(default)]
+ pub api_window_title: Option<String>,
}
#[cfg(unix)]
@@ -304,6 +309,7 @@ pub(crate) fn manifest_for(
panes: Vec<crate::handoff_runtime::HandoffRuntimeState>,
expected_protocol: Option<u32>,
expected_version: Option<String>,
+ api_window_title: Option<String>,
) -> HandoffManifest {
HandoffManifest {
version: HANDOFF_VERSION,
@@ -313,6 +319,7 @@ pub(crate) fn manifest_for(
expected_protocol,
snapshot,
panes,
+ api_window_title,
}
}
@@ -464,3 +471,54 @@ fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result<Vec<RawFd>> {
pub(crate) fn log_import_result(panes: usize) {
info!(panes, "handoff import ready");
}
+
+#[cfg(all(test, unix))]
+mod tests {
+ use super::*;
+
+ fn empty_snapshot() -> crate::persist::SessionSnapshot {
+ crate::persist::SessionSnapshot {
+ version: 0,
+ workspaces: Vec::new(),
+ active: None,
+ selected: 0,
+ sidebar_width: None,
+ sidebar_section_split: None,
+ collapsed_space_keys: Default::default(),
+ }
+ }
+
+ #[test]
+ fn a_handoff_carries_an_api_set_window_title() {
+ let manifest = manifest_for(
+ empty_snapshot(),
+ Vec::new(),
+ None,
+ None,
+ Some("deploying".to_string()),
+ );
+
+ assert_eq!(manifest.api_window_title.as_deref(), Some("deploying"));
+ }
+
+ #[test]
+ fn a_manifest_written_before_the_title_field_still_loads() {
+ let manifest = manifest_for(
+ empty_snapshot(),
+ Vec::new(),
+ None,
+ None,
+ Some("deploying".to_string()),
+ );
+ let mut value = serde_json::to_value(&manifest).expect("manifest should serialize");
+ value
+ .as_object_mut()
+ .expect("manifest should be a json object")
+ .remove("api_window_title");
+
+ let older: HandoffManifest =
+ serde_json::from_value(value).expect("an older manifest should still load");
+
+ assert!(older.api_window_title.is_none());
+ }
+}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 0632861c..3425225c 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -303,6 +303,14 @@ pub struct HeadlessServer {
next_client_id: u64,
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
foreground_client_id: Option<u64>,
+ /// Outer window title last pushed, paired with the client that received it.
+ /// Keying on the client means a newly attached terminal is written to even
+ /// when the title itself has not changed, without every code path that
+ /// changes the foreground client having to remember to invalidate this.
+ sent_window_title: Option<(u64, Option<String>)>,
+ /// Window title set through `client.window_title.set`. While present it wins
+ /// over the configured `ui.window_title` until the API clears it again.
+ api_window_title: Option<String>,
/// Server-owned keybindings, restored when foreground clients use server mode.
server_keybindings: crate::config::LiveKeybindConfig,
/// Full server config warning shown to clients that use server keybindings.
@@ -503,6 +511,8 @@ impl HeadlessServer {
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
+ sent_window_title: None,
+ api_window_title: None,
server_keybindings,
server_config_diagnostic,
server_config_diagnostic_without_keybindings,
@@ -572,13 +582,6 @@ impl HeadlessServer {
needs_render = true;
crate::render_prof::event("render.request.signal");
}
- let terminal_title_changed = self.app.sync_terminal_titles();
- if terminal_title_changed && self.app.terminal_title_sidebar_configured() {
- needs_render = true;
- needs_full_render = true;
- crate::render_prof::event("full_render_cause.terminal_title");
- }
-
// 2. Drain a bounded internal-event batch. API handlers perform an
// exhaustive forwarding-aware drain before reading pane/runtime state.
if self.drain_internal_events_with_forwarding() {
@@ -710,6 +713,22 @@ impl HeadlessServer {
needs_full_render = true;
crate::render_prof::event("full_render_cause.generic_dirty");
}
+ let (sidebar_title_changed, outer_title_synced) =
+ self.sync_terminal_title_sources(&render_request.terminal_title_sources);
+ if sidebar_title_changed {
+ needs_full_render = true;
+ crate::render_prof::event("full_render_cause.terminal_title_sidebar");
+ }
+ if needs_full_render && !outer_title_synced {
+ self.sync_window_title();
+ }
+ if !needs_full_render && !needs_graphics_render && !pty_dirty {
+ // A synchronized-output OSC title can be the only pending work.
+ // Its deferred PTY repaint has its own signal; do not manufacture
+ // a full UI render for this client-local side effect.
+ needs_render = false;
+ continue;
+ }
if needs_full_render {
crate::render_prof::event("retained_gate.needs_full_render");
} else if !pty_dirty {
@@ -1278,6 +1297,7 @@ impl HeadlessServer {
panes,
params.expected_protocol,
params.expected_version,
+ self.api_window_title.clone(),
);
let mut import_child = match crate::server::handoff::spawn_handoff_import(
import_exe.as_deref(),
@@ -2122,11 +2142,93 @@ impl HeadlessServer {
.unwrap_or_else(|_| "{}".to_string())
}
+ /// Pulls only titles reported dirty by the PTY parser. A focused pane title
+ /// is forwarded as an independent client side effect; only sidebar title
+ /// tokens require a UI render.
+ fn sync_terminal_title_sources(
+ &mut self,
+ sources: &HashSet<crate::layout::PaneId>,
+ ) -> (bool, bool) {
+ let focused_source = self
+ .app
+ .state
+ .active
+ .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx))
+ .and_then(|workspace| workspace.focused_pane_id())
+ .is_some_and(|pane_id| sources.contains(&pane_id));
+ let changes = self.app.sync_terminal_titles(sources);
+ let outer_title_synced = focused_source && self.app.window_title_uses_terminal_title();
+ if outer_title_synced {
+ self.sync_window_title();
+ }
+ (
+ self.app.terminal_title_sidebar_changed(&changes),
+ outer_title_synced,
+ )
+ }
+
+ /// Renders `ui.window_title` against current session state. `None` means
+ /// window titles are disabled or every token resolved empty, which leaves
+ /// the client on Herdr's default title.
+ fn configured_window_title(&self) -> Option<String> {
+ self.app
+ .window_title()
+ .and_then(|title| crate::config::sanitize_window_title_text(&title))
+ }
+
+ /// Pushes the configured outer window title to the foreground client when it
+ /// changed. Herdr consumes each pane's own `OSC 0`/`OSC 2`, so without this
+ /// the host terminal title never follows the session — which is what window
+ /// managers read for tab and group bar labels.
+ fn sync_window_title(&mut self) {
+ let title = match &self.api_window_title {
+ Some(title) => Some(title.clone()),
+ None if self.app.window_title_configured() => self.configured_window_title(),
+ None => return,
+ };
+ if let (Some(client_id), Some((sent_client_id, sent_title))) =
+ (self.foreground_client_id, self.sent_window_title.as_ref())
+ {
+ if *sent_client_id == client_id && *sent_title == title {
+ return;
+ }
+ }
+ self.send_window_title(title);
+ }
+
+ /// Sends a window title and remembers it only when a foreground client took
+ /// it, so the next client to attach is written to rather than skipped.
+ fn send_window_title(&mut self, title: Option<String>) -> bool {
+ let Some(client_id) = self.foreground_client_id else {
+ self.sent_window_title = None;
+ return false;
+ };
+ // A detached client keeps its entry with no writer, and a targeted send
+ // to one reports success without queuing anything. Caching the title
+ // against that client would skip the send once it attaches again.
+ if self
+ .clients
+ .get(&client_id)
+ .is_none_or(|client| client.writer.is_none())
+ {
+ self.sent_window_title = None;
+ return false;
+ }
+ let sent = self.send_to_client(
+ client_id,
+ ServerMessage::WindowTitle {
+ title: title.clone(),
+ },
+ );
+ self.sent_window_title = sent.then_some((client_id, title));
+ sent
+ }
+
fn handle_client_window_title_api(&mut self, id: String, title: Option<String>) -> String {
use api::schema::{ClientWindowTitleReason, ResponseResult};
let title = match title {
- Some(title) => match sanitize_window_title_text(&title, 200) {
+ Some(title) => match crate::config::sanitize_window_title_text(&title) {
Some(title) => Some(title),
None => {
return serde_json::to_string(&api::schema::ErrorResponse {
@@ -2142,7 +2244,11 @@ impl HeadlessServer {
None => None,
};
let set_title = title.is_some();
- let changed = self.send_to_foreground_client(ServerMessage::WindowTitle { title });
+ // An explicit title suppresses `ui.window_title` until it is cleared,
+ // and clearing restores the configured title rather than only "herdr".
+ self.api_window_title = title.clone();
+ let title = title.or_else(|| self.configured_window_title());
+ let changed = self.send_window_title(title);
let reason = match (changed, set_title) {
(true, true) => ClientWindowTitleReason::Set,
(true, false) => ClientWindowTitleReason::Cleared,
@@ -4828,17 +4934,6 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> {
(!sanitized.is_empty()).then_some(sanitized)
}
-fn sanitize_window_title_text(value: &str, max_chars: usize) -> Option<String> {
- let sanitized = value
- .chars()
- .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control())
- .take(max_chars)
- .collect::<String>()
- .trim()
- .to_string();
- (!sanitized.is_empty()).then_some(sanitized)
-}
-
fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) {
let without_keybindings = diagnostics
.iter()
@@ -5045,6 +5140,9 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
Some(api_server),
should_quit,
)?;
+ // Carried across before any client attaches, so the first title sent is
+ // the override rather than the configured one it replaced.
+ server.api_window_title = received.manifest.api_window_title.take();
crate::server::handoff::report_ready(&mut received.stream)?;
crate::server::handoff::wait_committed(&mut received.stream)?;
server.app.assume_handoff_ownership();
@@ -5207,6 +5305,8 @@ mod tests {
#[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
+ sent_window_title: None,
+ api_window_title: None,
server_keybindings,
server_config_diagnostic: None,
server_config_diagnostic_without_keybindings: None,
@@ -5294,6 +5394,7 @@ mod tests {
.app
.terminal_runtimes
.insert(terminal_id.clone(), runtime);
+ server.app.render_dirty.request_terminal_title(pane_id);
let first = headless_pane_list(&mut server).pop().unwrap();
assert_eq!(first.terminal_title.as_deref(), Some("⠋ task"));
@@ -5319,6 +5420,7 @@ mod tests {
.get(&terminal_id)
.unwrap()
.test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\");
+ server.app.render_dirty.request_terminal_title(pane_id);
let second = headless_pane_list(&mut server).pop().unwrap();
assert_eq!(second.terminal_title.as_deref(), Some("⠙ task"));
assert_eq!(second.terminal_title_stripped.as_deref(), Some("task"));
@@ -5473,6 +5575,311 @@ mod tests {
shutdown_test_runtimes(&mut server);
}
+ fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver<Vec<u8>>) {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
+ server.app.state.active = Some(0);
+ server.app.state.selected = 0;
+
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(1);
+ drain_window_titles(&control_rx);
+ (server, control_rx)
+ }
+
+ /// The test client writer drains its queue on a background thread, so
+ /// reading a pushed message needs a timeout rather than `try_recv`.
+ fn next_window_title(
+ control_rx: &std::sync::mpsc::Receiver<Vec<u8>>,
+ ) -> Option<Option<String>> {
+ let deadline = Instant::now() + Duration::from_secs(5);
+ while let Some(remaining) = deadline.checked_duration_since(Instant::now()) {
+ let Ok(bytes) = control_rx.recv_timeout(remaining) else {
+ return None;
+ };
+ if let ServerMessage::WindowTitle { title } = read_server_message(bytes) {
+ return Some(title);
+ }
+ }
+ None
+ }
+
+ fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) {
+ while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {}
+ }
+
+ fn no_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> bool {
+ while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) {
+ if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) {
+ return false;
+ }
+ }
+ true
+ }
+
+ #[test]
+ fn window_title_waits_for_a_foreground_client_to_exist() {
+ let mut server = test_headless_server();
+ server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")];
+ server.app.state.active = Some(0);
+ server.app.configure_window_title("{workspace}");
+
+ // The server renders before the first client attaches. Nothing was
+ // delivered, so nothing may be recorded as delivered either.
+ server.sync_window_title();
+ assert_eq!(server.sent_window_title, None);
+
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 1,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(1);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() {
+ let (mut server, first_control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&first_control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ // ClientConnected assigns the foreground client directly rather than
+ // going through promote_client_to_foreground, so the cache must notice
+ // the new client on its own.
+ let (client_tx, second_control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&second_control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn configured_window_title_reaches_the_foreground_client_once_per_change() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}/{tab}");
+
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd/1".to_string()))
+ );
+
+ // An unchanged title must not re-emit an OSC on every render.
+ server.sync_window_title();
+ assert!(no_window_title(&control_rx));
+
+ server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into());
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd/build".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[tokio::test]
+ async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{terminal_title}");
+ server.app.state.ensure_test_terminals();
+ let pane_id = server.app.state.workspaces[0].tabs[0].root_pane;
+ let terminal_id = server.app.state.workspaces[0]
+ .terminal_id(pane_id)
+ .expect("terminal")
+ .clone();
+ let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"");
+ runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes());
+ server
+ .app
+ .terminal_runtimes
+ .insert(terminal_id.clone(), runtime);
+
+ assert_eq!(
+ server.sync_terminal_title_sources(&HashSet::from([pane_id])),
+ (false, true)
+ );
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("building".to_string()))
+ );
+
+ server
+ .app
+ .terminal_runtimes
+ .get(&terminal_id)
+ .expect("runtime")
+ .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes());
+ assert_eq!(
+ server.sync_terminal_title_sources(&HashSet::from([pane_id])),
+ (false, true)
+ );
+ assert!(no_window_title(&control_rx));
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() {
+ let (mut server, _control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+
+ // A detached client keeps its entry but loses its writer, so nothing
+ // reaches a terminal even though the targeted send reports success.
+ if let Some(client) = server.clients.get_mut(&1) {
+ client.writer = None;
+ }
+ server.sync_window_title();
+ assert!(server.sent_window_title.is_none());
+
+ // Attaching again has to deliver the title rather than skip it as sent.
+ let (client_tx, control_rx, _render_rx) = test_client_writer();
+ if let Some(client) = server.clients.get_mut(&1) {
+ client.writer = Some(client_tx);
+ }
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn empty_window_title_config_leaves_the_outer_title_alone() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("");
+
+ server.sync_window_title();
+
+ assert!(no_window_title(&control_rx));
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn api_window_title_wins_until_it_is_cleared() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+
+ server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herdr api".to_string()))
+ );
+
+ server.app.state.workspaces[0].custom_name = Some("ops".into());
+ server.sync_window_title();
+ assert!(no_window_title(&control_rx));
+
+ // Clearing hands the title back to ui.window_title, not to "herdr".
+ server.handle_client_window_title_api("clear".into(), None);
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("ops".to_string()))
+ );
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() {
+ let (mut server, control_rx) = window_title_test_server();
+ server.app.configure_window_title("");
+
+ server.handle_client_window_title_api("set".into(), Some("herdr api".into()));
+ assert_eq!(
+ next_window_title(&control_rx),
+ Some(Some("herdr api".to_string()))
+ );
+
+ server.handle_client_window_title_api("clear".into(), None);
+ assert_eq!(next_window_title(&control_rx), Some(None));
+
+ shutdown_test_runtimes(&mut server);
+ }
+
+ #[test]
+ fn a_newly_promoted_client_gets_the_window_title_again() {
+ let (mut server, first_control_rx) = window_title_test_server();
+ server.app.configure_window_title("{workspace}");
+ server.sync_window_title();
+ assert_eq!(
+ next_window_title(&first_control_rx),
+ Some(Some("herd".to_string()))
+ );
+
+ // A second terminal starts on whatever its shell or ssh left behind.
+ let (client_tx, second_control_rx, _render_rx) = test_client_writer();
+ server.clients.insert(
+ 2,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ None,
+ 2,
+ RenderEncoding::SemanticFrame,
+ Some(client_tx),
+ ),
+ );
+ server.promote_client_to_foreground(2);
+ server.sync_window_title();
+
+ assert_eq!(
+ next_window_title(&second_control_rx),
+ Some(Some("herd".to_string()))
+ );
+ shutdown_test_runtimes(&mut server);
+ }
+
fn test_client_writer() -> (
ClientWriter,
std::sync::mpsc::Receiver<Vec<u8>>,
diff --git a/src/terminal_effects.rs b/src/terminal_effects.rs
index 88fcf55f..91d7b363 100644
--- a/src/terminal_effects.rs
+++ b/src/terminal_effects.rs
@@ -12,6 +12,16 @@ pub(crate) fn write_terminal_bells<W: Write>(writer: &mut W, count: u16) -> io::
writer.flush()
}
+pub(crate) fn write_window_title<W: Write>(writer: &mut W, title: Option<&str>) -> io::Result<()> {
+ let title = title.unwrap_or("herdr");
+ let safe_title = title
+ .chars()
+ .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}'))
+ .collect::<String>();
+ write!(writer, "\x1b]0;{safe_title}\x07")?;
+ writer.flush()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -24,4 +34,15 @@ mod tests {
assert_eq!(output, vec![b'\x07'; 130]);
}
+
+ #[test]
+ fn window_title_strips_terminators_and_defaults_to_herdr() {
+ let mut output = Vec::new();
+ write_window_title(&mut output, Some("herdr\x1b api\u{7}\u{9c}")).unwrap();
+ assert_eq!(output, b"\x1b]0;herdr api\x07");
+
+ output.clear();
+ write_window_title(&mut output, None).unwrap();
+ assert_eq!(output, b"\x1b]0;herdr\x07");
+ }
}