diff options
Diffstat (limited to 'src/app')
| -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 |
4 files changed, 306 insertions, 40 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("[]")); + } +} |
