diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api.rs | 2 | ||||
| -rw-r--r-- | src/app/mod.rs | 30 | ||||
| -rw-r--r-- | src/app/terminal_titles.rs | 143 | ||||
| -rw-r--r-- | src/app/window_title.rs | 171 | ||||
| -rw-r--r-- | src/client/mod.rs | 28 | ||||
| -rw-r--r-- | src/config.rs | 4 | ||||
| -rw-r--r-- | src/config/model.rs | 4 | ||||
| -rw-r--r-- | src/config/window_title.rs | 205 | ||||
| -rw-r--r-- | src/main.rs | 8 | ||||
| -rw-r--r-- | src/pane.rs | 10 | ||||
| -rw-r--r-- | src/pane/osc.rs | 10 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 32 | ||||
| -rw-r--r-- | src/render_signal.rs | 38 | ||||
| -rw-r--r-- | src/server/handoff.rs | 58 | ||||
| -rw-r--r-- | src/server/headless.rs | 447 | ||||
| -rw-r--r-- | src/terminal_effects.rs | 21 |
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"); + } } |
