diff options
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/model.rs | 4 | ||||
| -rw-r--r-- | src/config/window_title.rs | 205 |
2 files changed, 209 insertions, 0 deletions
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)] + ); + } +} |
