aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-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
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("[]"));
+ }
+}