From e48d83067a5a9b05dfd0a82761068866ab2d294d Mon Sep 17 00:00:00 2001 From: Can Celik Date: Mon, 10 Aug 2026 01:20:09 +0300 Subject: feat: add configurable tab bar status (#2586) * feat: show a zoom indicator in the desktop tab bar Reserve the right edge of the tab row for a ZOOM pill while the focused pane is zoomed, matching the accent style of the mode bars. The per-tab Z suffix stays; the pill makes the zoomed state visible at a glance like tmux's status-right flag. * feat: optionally show the hostname in the desktop tab bar Add ui.tab_bar_hostname to display the machine's hostname at the right edge of the tab row, like tmux's #h in status-right. The value resolves where the server renders, so remote sessions show the remote host. Off by default. * fix: strip control characters from the tab bar hostname * fix: hide the hostname when it would squeeze out the tab strip * feat: add configurable tab bar status * fix: harden tab bar status updates * fix: terminate tab bar status process trees * fix: disable status commands on unsupported platforms * fix: skip unchanged status command renders * fix: keep tab bar status opt-in by default --------- Co-authored-by: David Heinemeier Hansson --- src/app/actions.rs | 3 +- src/app/api.rs | 15 ++ src/app/input/navigate.rs | 2 +- src/app/mod.rs | 55 ++++- src/app/runtime.rs | 2 + src/app/state.rs | 10 + src/app/tab_bar_status.rs | 590 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 673 insertions(+), 4 deletions(-) create mode 100644 src/app/tab_bar_status.rs (limited to 'src/app') diff --git a/src/app/actions.rs b/src/app/actions.rs index 20deab2a..5d8051fd 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1748,7 +1748,7 @@ impl AppState { let layout = crate::ui::compute_tab_bar_view( ws, - area, + crate::ui::tab_bar_content_area(self, area), self.tab_scroll, self.tab_scroll_follow_active, self.mouse_capture, @@ -2935,6 +2935,7 @@ impl AppState { } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), + AppEvent::TabBarCommandFinished { .. } => Vec::new(), AppEvent::PluginCommandFinished { .. } => Vec::new(), } } diff --git a/src/app/api.rs b/src/app/api.rs index 0ccd9b21..379369ae 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -64,6 +64,11 @@ impl App { results, cache_updates, } => self.handle_git_status_refreshed(results, cache_updates), + AppEvent::TabBarCommandFinished { + generation, + segment_index, + result, + } => self.handle_tab_bar_command_finished(generation, segment_index, result), ev @ AppEvent::TerminalBell { .. } => { self.handle_internal_event(ev); false @@ -144,6 +149,16 @@ impl App { return; } + if let AppEvent::TabBarCommandFinished { + generation, + segment_index, + result, + } = ev + { + let _ = self.handle_tab_bar_command_finished(generation, segment_index, result); + return; + } + if let AppEvent::PluginCommandFinished { log_id, finished_unix_ms, diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 73dc5299..d4d714a9 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -858,7 +858,7 @@ impl App { ) } - fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { + pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option) { let mut env = vec![( crate::api::SOCKET_PATH_ENV_VAR.to_string(), crate::api::socket_path().display().to_string(), diff --git a/src/app/mod.rs b/src/app/mod.rs index 15f352af..cca530ce 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -22,6 +22,7 @@ mod runtime; mod runtime_mutations; mod session; pub mod state; +mod tab_bar_status; mod terminal_targets; mod terminal_titles; mod theme_sync; @@ -139,6 +140,10 @@ pub struct App { pub(crate) session_save_deadline: Option, pub(crate) session_save_thread: Option>, pub(crate) detached_custom_command_children: Vec, + tab_bar_status_generation: u64, + tab_bar_datetimes: Vec, + tab_bar_commands: Vec, + next_tab_bar_datetime_refresh: Option, pub(crate) persist_pane_history: bool, pub(crate) last_render_at: Option, pub(crate) input_leases: input::InputLeaseTable, @@ -642,6 +647,8 @@ impl App { show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, hide_tab_bar_when_single_tab: config.ui.hide_tab_bar_when_single_tab, tab_bar_position: config.ui.tab_bar_position, + tab_bar_right: Vec::new(), + tab_bar_right_separator: String::new(), pane_history_persistence: config.experimental.pane_history, reveal_hidden_cursor_for_cjk_ime: config.experimental.reveal_hidden_cursor_for_cjk_ime, cjk_ime_agent_filter_configured: !config.experimental.cjk_ime_agents.is_empty(), @@ -723,7 +730,7 @@ impl App { .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id))) }); - Self { + let mut app = Self { config_diagnostic_deadline: None, toast_deadline: None, copy_feedback_deadline: None, @@ -762,6 +769,10 @@ impl App { session_save_deadline: None, session_save_thread: None, detached_custom_command_children: Vec::new(), + tab_bar_status_generation: 0, + tab_bar_datetimes: Vec::new(), + tab_bar_commands: Vec::new(), + next_tab_bar_datetime_refresh: None, selection_autoscroll_deadline: None, selection_highlight_clear_deadline: None, persist_pane_history: config.experimental.pane_history, @@ -781,7 +792,9 @@ impl App { local_input_source_switch: true, config_reloaded_from_disk: false, 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 } #[cfg(unix)] @@ -1421,6 +1434,9 @@ impl App { diagnostics.push(format!("{diagnostic}; keeping previous [ui] settings")); } else { diagnostics.extend(config.ui.sound.diagnostics()); + diagnostics.extend(crate::config::tab_bar_right_diagnostics( + &config.ui.tab_bar_right, + )); self.state.default_sidebar_width = config.ui.sidebar_width; if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault { @@ -1460,6 +1476,10 @@ impl App { config.ui.show_agent_labels_on_pane_borders; self.state.hide_tab_bar_when_single_tab = config.ui.hide_tab_bar_when_single_tab; self.state.tab_bar_position = config.ui.tab_bar_position; + self.configure_tab_bar_status( + &config.ui.tab_bar_right, + &config.ui.tab_bar_right_separator, + ); self.state.agent_panel_sort = agent_panel_sort_from_config(config.ui.agent_panel_sort); self.state.status_indicators = config.ui.status_indicators; @@ -2331,6 +2351,37 @@ mod tests { assert!(!app.git_refresh_in_flight); } + #[test] + fn tab_bar_command_events_render_only_when_visible_output_changes() { + if !crate::platform::status_commands_supported() { + return; + } + + let mut app = test_app(); + app.configure_tab_bar_status( + &[crate::config::TabBarRightEntryConfig::Command { + command: "status".into(), + interval_seconds: 5, + timeout_seconds: 2, + }], + " ", + ); + let generation = app.tab_bar_status_generation; + let event = |generation, output: Option<&str>| AppEvent::TabBarCommandFinished { + generation, + segment_index: 0, + result: Ok(output.map(str::to_string)), + }; + + assert!(!app.handle_internal_event_with_prefix_sync(event(generation, None))); + assert!(app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_prefix_sync(event(generation, Some("ready")))); + assert!(!app.handle_internal_event_with_prefix_sync(event( + generation.wrapping_add(1), + Some("stale"), + ))); + } + #[test] fn git_status_event_clears_in_flight_refresh() { let mut app = test_app(); diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 31426f87..9551055e 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -385,6 +385,7 @@ impl App { } changed |= self.expire_due_metadata(now); + changed |= self.handle_tab_bar_status_tasks(now); if geometry_dirty || resized { self.pending_agent_resume_deadline = None; @@ -610,6 +611,7 @@ impl App { self.session_save_deadline, self.selection_autoscroll_deadline, self.selection_highlight_clear_deadline, + self.next_tab_bar_status_deadline(), render_deadline, ] .into_iter() diff --git a/src/app/state.rs b/src/app/state.rs index e460f62b..cbecef00 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -1317,6 +1317,12 @@ pub(crate) struct PaneFocusTarget { /// All application state — pure data, no channels or async runtime. /// Testable without PTYs or a tokio runtime. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TabBarStatusSegment { + Zoom, + Text(Option), +} + pub struct AppState { pub terminals: std::collections::HashMap, @@ -1432,6 +1438,8 @@ pub struct AppState { pub show_agent_labels_on_pane_borders: bool, pub hide_tab_bar_when_single_tab: bool, pub tab_bar_position: TabBarPositionConfig, + pub tab_bar_right: Vec, + pub tab_bar_right_separator: String, pub pane_history_persistence: bool, /// Expose the focused pane's cursor anchor to the outer terminal even when /// the pane requested `?25l`. See `[experimental] reveal_hidden_cursor_for_cjk_ime`. @@ -1798,6 +1806,8 @@ impl AppState { show_agent_labels_on_pane_borders: false, hide_tab_bar_when_single_tab: false, tab_bar_position: TabBarPositionConfig::Top, + tab_bar_right: Vec::new(), + tab_bar_right_separator: " ".into(), pane_history_persistence: false, reveal_hidden_cursor_for_cjk_ime: false, cjk_ime_agent_filter_configured: false, diff --git a/src/app/tab_bar_status.rs b/src/app/tab_bar_status.rs new file mode 100644 index 00000000..54903db6 --- /dev/null +++ b/src/app/tab_bar_status.rs @@ -0,0 +1,590 @@ +use std::{process::Stdio, time::Duration}; + +use tokio::io::AsyncReadExt; + +use super::{state::TabBarStatusSegment, App}; +use crate::config::TabBarRightEntryConfig; + +const DATETIME_REFRESH_INTERVAL: Duration = Duration::from_secs(1); +const MAX_COMMAND_LINE_BYTES: usize = 4096; +const MAX_STATUS_TEXT_CHARS: usize = 80; + +pub(super) struct TabBarDatetimeRuntime { + segment_index: usize, + format: time::format_description::OwnedFormatItem, +} + +pub(super) struct TabBarCommandRuntime { + segment_index: usize, + command: String, + interval: Duration, + timeout: Duration, + next_run_at: std::time::Instant, + task: Option, +} + +impl Drop for TabBarCommandRuntime { + fn drop(&mut self) { + if let Some(task) = self.task.take() { + task.abort(); + } + } +} + +impl App { + pub(super) fn configure_tab_bar_status( + &mut self, + entries: &[TabBarRightEntryConfig], + separator: &str, + ) { + self.tab_bar_status_generation = self.tab_bar_status_generation.wrapping_add(1); + self.tab_bar_datetimes.clear(); + self.tab_bar_commands.clear(); + self.state.tab_bar_right.clear(); + self.state.tab_bar_right_separator = sanitize_separator(separator); + + let now = std::time::Instant::now(); + for entry in entries + .iter() + .take(crate::config::MAX_TAB_BAR_RIGHT_ENTRIES) + { + match entry { + TabBarRightEntryConfig::Zoom => { + self.state.tab_bar_right.push(TabBarStatusSegment::Zoom); + } + TabBarRightEntryConfig::Hostname => { + self.state + .tab_bar_right + .push(TabBarStatusSegment::Text(sanitize_status_text( + crate::platform::hostname().as_deref().unwrap_or_default(), + ))); + } + TabBarRightEntryConfig::Datetime { format } => { + let Ok(format) = crate::config::parse_tab_bar_datetime_format(format) else { + continue; + }; + let value = format_local_datetime(&format); + let segment_index = self.state.tab_bar_right.len(); + self.state + .tab_bar_right + .push(TabBarStatusSegment::Text(value)); + self.tab_bar_datetimes.push(TabBarDatetimeRuntime { + segment_index, + format, + }); + } + TabBarRightEntryConfig::Text { text } => { + self.state + .tab_bar_right + .push(TabBarStatusSegment::Text(sanitize_literal_text(text))); + } + TabBarRightEntryConfig::Command { + command, + interval_seconds, + timeout_seconds, + } => { + if !crate::platform::status_commands_supported() + || command.trim().is_empty() + || *interval_seconds == 0 + || *interval_seconds > crate::config::MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS + || *timeout_seconds == 0 + || *timeout_seconds > crate::config::MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS + { + continue; + } + let segment_index = self.state.tab_bar_right.len(); + self.state + .tab_bar_right + .push(TabBarStatusSegment::Text(None)); + self.tab_bar_commands.push(TabBarCommandRuntime { + segment_index, + command: command.clone(), + interval: Duration::from_secs(*interval_seconds), + timeout: Duration::from_secs(*timeout_seconds), + next_run_at: now, + task: None, + }); + } + } + } + + self.next_tab_bar_datetime_refresh = + (!self.tab_bar_datetimes.is_empty()).then_some(now + DATETIME_REFRESH_INTERVAL); + } + + pub(crate) fn handle_tab_bar_status_tasks(&mut self, now: std::time::Instant) -> bool { + let mut changed = false; + + if self + .next_tab_bar_datetime_refresh + .is_some_and(|deadline| now >= deadline) + { + for runtime in &self.tab_bar_datetimes { + let value = format_local_datetime(&runtime.format); + if let Some(TabBarStatusSegment::Text(current)) = + self.state.tab_bar_right.get_mut(runtime.segment_index) + { + changed |= *current != value; + *current = value; + } + } + self.next_tab_bar_datetime_refresh = Some(now + DATETIME_REFRESH_INTERVAL); + } + + let command_due = self + .tab_bar_commands + .iter() + .any(|runtime| runtime.task.is_none() && now >= runtime.next_run_at); + if !command_due { + return changed; + } + + let generation = self.tab_bar_status_generation; + let (environment, cwd) = self.custom_command_env(); + for runtime in &mut self.tab_bar_commands { + if runtime.task.is_some() || now < runtime.next_run_at { + continue; + } + runtime.next_run_at = now.checked_add(runtime.interval).unwrap_or(now); + runtime.task = Some(spawn_status_command( + self.event_tx.clone(), + generation, + runtime.segment_index, + runtime.command.clone(), + runtime.timeout, + environment.clone(), + cwd.clone(), + )); + } + + changed + } + + pub(crate) fn next_tab_bar_status_deadline(&self) -> Option { + self.tab_bar_commands + .iter() + .filter(|runtime| runtime.task.is_none()) + .map(|runtime| runtime.next_run_at) + .chain(self.next_tab_bar_datetime_refresh) + .min() + } + + pub(super) fn handle_tab_bar_command_finished( + &mut self, + generation: u64, + segment_index: usize, + result: Result, String>, + ) -> bool { + if generation != self.tab_bar_status_generation { + return false; + } + let Some(runtime) = self + .tab_bar_commands + .iter_mut() + .find(|runtime| runtime.segment_index == segment_index) + else { + return false; + }; + runtime.task = None; + + let output = match result { + Ok(output) => output, + Err(error) => { + tracing::warn!(command = %runtime.command, error, "tab bar status command failed"); + None + } + }; + let Some(TabBarStatusSegment::Text(current)) = + self.state.tab_bar_right.get_mut(segment_index) + else { + return false; + }; + let changed = *current != output; + *current = output; + changed + } +} + +fn format_local_datetime(format: &time::format_description::OwnedFormatItem) -> Option { + let datetime = crate::platform::local_datetime()?; + datetime + .format(format) + .ok() + .and_then(|value| sanitize_status_text(&value)) +} + +fn sanitize_separator(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_control()) + .collect() +} + +fn sanitize_literal_text(value: &str) -> Option { + let value: String = value + .chars() + .filter(|character| !character.is_control()) + .collect(); + (!value.is_empty()).then_some(value) +} + +fn sanitize_status_text(value: &str) -> Option { + let value: String = value + .trim() + .chars() + .filter(|character| !character.is_control() && !is_unicode_format_control(*character)) + .take(MAX_STATUS_TEXT_CHARS) + .collect(); + (!value.is_empty()).then_some(value) +} + +fn is_unicode_format_control(character: char) -> bool { + matches!( + character, + '\u{00ad}' + | '\u{0600}'..='\u{0605}' + | '\u{061c}' + | '\u{06dd}' + | '\u{070f}' + | '\u{0890}'..='\u{0891}' + | '\u{08e2}' + | '\u{17b4}'..='\u{17b5}' + | '\u{180e}' + | '\u{200b}'..='\u{200f}' + | '\u{202a}'..='\u{202e}' + | '\u{2060}'..='\u{206f}' + | '\u{feff}' + | '\u{fff9}'..='\u{fffb}' + | '\u{110bd}' + | '\u{110cd}' + | '\u{13430}'..='\u{1343f}' + | '\u{1bca0}'..='\u{1bca3}' + | '\u{1d173}'..='\u{1d17a}' + | '\u{e0001}' + | '\u{e0020}'..='\u{e007f}' + ) +} + +fn command_output_text(output: &[u8]) -> Option { + let output = String::from_utf8_lossy(output); + output.lines().next_back().and_then(sanitize_status_text) +} + +async fn read_last_output_line( + mut stdout: tokio::process::ChildStdout, +) -> std::io::Result> { + let mut current_line = Vec::new(); + let mut last_line = Vec::new(); + let mut ended_with_newline = false; + let mut buffer = [0_u8; 1024]; + + loop { + let count = stdout.read(&mut buffer).await?; + if count == 0 { + break; + } + for &byte in &buffer[..count] { + if byte == b'\n' { + last_line = std::mem::take(&mut current_line); + ended_with_newline = true; + } else { + if current_line.len() < MAX_COMMAND_LINE_BYTES { + current_line.push(byte); + } + ended_with_newline = false; + } + } + } + + Ok(if ended_with_newline { + last_line + } else { + current_line + }) +} + +fn spawn_status_command( + event_tx: tokio::sync::mpsc::Sender, + generation: u64, + segment_index: usize, + command: String, + timeout: Duration, + environment: Vec<(String, String)>, + cwd: Option, +) -> tokio::task::AbortHandle { + let task = tokio::spawn(async move { + let mut process = crate::platform::detached_custom_command_process(&command); + process + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .envs(environment); + if let Some(cwd) = cwd { + process.current_dir(cwd); + } + crate::platform::configure_status_command(&mut process); + + let mut process = tokio::process::Command::from(process); + process.kill_on_drop(true); + let operation = async move { + let mut child = process.spawn().map_err(|error| error.to_string())?; + let _guard = crate::platform::StatusCommandGuard::new(&child) + .map_err(|error| error.to_string())?; + let stdout = child.stdout.take(); + let read_output = async { + let Some(stdout) = stdout else { + return std::io::Result::Ok(Vec::new()); + }; + read_last_output_line(stdout).await + }; + let (status, output) = tokio::join!(child.wait(), read_output); + let status = status.map_err(|error| error.to_string())?; + let output = output.map_err(|error| error.to_string())?; + if status.success() { + Ok(command_output_text(&output)) + } else { + Err(format!("exited with {status}")) + } + }; + let result = match tokio::time::timeout(timeout, operation).await { + Ok(result) => result, + Err(_) => Err(format!("timed out after {}s", timeout.as_secs())), + }; + let _ = event_tx + .send(crate::events::AppEvent::TabBarCommandFinished { + generation, + segment_index, + result, + }) + .await; + }); + task.abort_handle() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{config::Config, events::AppEvent}; + + fn test_app() -> App { + let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); + App::new( + &Config::default(), + true, + None, + api_rx, + crate::api::EventHub::default(), + ) + } + + #[cfg(unix)] + const MULTILINE_COMMAND: &str = "printf 'old\\nfinal\\n'"; + #[cfg(windows)] + const MULTILINE_COMMAND: &str = "echo old & echo final"; + + #[cfg(unix)] + const OVER_CAP_COMMAND: &str = "head -c 5000 /dev/zero | tr '\\0' x; printf '\\nREADY\\n'"; + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn unique_temp_path(name: &str) -> std::path::PathBuf { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock after epoch") + .as_nanos(); + std::path::PathBuf::from("/var/tmp").join(format!( + "herdr-tab-status-{name}-{}-{stamp}", + std::process::id() + )) + } + + #[tokio::test] + async fn status_command_reports_its_sanitized_last_line() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(1); + spawn_status_command( + event_tx, + 7, + 3, + MULTILINE_COMMAND.into(), + Duration::from_secs(2), + Vec::new(), + None, + ); + + let event = tokio::time::timeout(Duration::from_secs(3), event_rx.recv()) + .await + .expect("status command timed out") + .expect("status command event channel closed"); + assert!(matches!( + event, + AppEvent::TabBarCommandFinished { + generation: 7, + segment_index: 3, + result: Ok(Some(ref output)), + } if output == "final" + )); + } + + #[cfg(unix)] + #[tokio::test] + async fn status_command_drains_large_output_and_keeps_the_last_line() { + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(1); + spawn_status_command( + event_tx, + 7, + 3, + OVER_CAP_COMMAND.into(), + Duration::from_secs(2), + Vec::new(), + None, + ); + + let event = tokio::time::timeout(Duration::from_secs(3), event_rx.recv()) + .await + .expect("status command timed out") + .expect("status command event channel closed"); + assert!(matches!( + event, + AppEvent::TabBarCommandFinished { + result: Ok(Some(ref output)), + .. + } if output == "READY" + )); + } + + #[test] + fn stale_command_result_does_not_replace_reloaded_status() { + let mut app = test_app(); + app.configure_tab_bar_status( + &[TabBarRightEntryConfig::Command { + command: MULTILINE_COMMAND.into(), + interval_seconds: 5, + timeout_seconds: 2, + }], + " ", + ); + let stale_generation = app.tab_bar_status_generation; + app.configure_tab_bar_status( + &[TabBarRightEntryConfig::Text { + text: "fresh".into(), + }], + " ", + ); + + app.handle_tab_bar_command_finished(stale_generation, 0, Ok(Some("stale".into()))); + + assert_eq!( + app.state.tab_bar_right, + vec![TabBarStatusSegment::Text(Some("fresh".into()))] + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[tokio::test] + async fn reload_aborts_an_in_flight_command_task_and_its_descendants() { + let started = unique_temp_path("started"); + let survived = unique_temp_path("survived"); + let command = format!( + "printf started > {}; (sleep 0.3; printf survived > {}) & wait", + started.display(), + survived.display() + ); + let mut app = test_app(); + app.configure_tab_bar_status( + &[TabBarRightEntryConfig::Command { + command, + interval_seconds: 5, + timeout_seconds: 20, + }], + " ", + ); + app.handle_tab_bar_status_tasks(std::time::Instant::now()); + for _ in 0..50 { + if started.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(started.exists(), "status command did not start"); + + app.configure_tab_bar_status( + &[TabBarRightEntryConfig::Text { + text: "reloaded".into(), + }], + " ", + ); + + assert!( + tokio::time::timeout(Duration::from_millis(100), app.event_rx.recv()) + .await + .is_err() + ); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!(!survived.exists(), "status command descendant survived"); + let _ = std::fs::remove_file(started); + let _ = std::fs::remove_file(survived); + } + + #[tokio::test] + async fn in_flight_command_has_no_second_deadline() { + let mut app = test_app(); + app.configure_tab_bar_status( + &[TabBarRightEntryConfig::Command { + command: MULTILINE_COMMAND.into(), + interval_seconds: 5, + timeout_seconds: 2, + }], + " ", + ); + + let now = std::time::Instant::now(); + assert!(app.next_tab_bar_status_deadline().is_some()); + app.handle_tab_bar_status_tasks(now); + + assert!(app.tab_bar_commands[0].task.is_some()); + assert_eq!(app.next_tab_bar_status_deadline(), None); + } + + #[test] + fn datetime_refresh_updates_its_segment_once_per_deadline() { + let mut app = test_app(); + app.configure_tab_bar_status( + &[TabBarRightEntryConfig::Datetime { + format: "%Y-%m-%d %H:%M:%S".into(), + }], + " ", + ); + app.state.tab_bar_right[0] = TabBarStatusSegment::Text(None); + let deadline = app + .next_tab_bar_datetime_refresh + .expect("datetime refresh deadline"); + + assert!(app.handle_tab_bar_status_tasks(deadline)); + assert!(matches!( + &app.state.tab_bar_right[0], + TabBarStatusSegment::Text(Some(value)) if !value.is_empty() + )); + assert!(!app.handle_tab_bar_status_tasks(deadline)); + } + + #[test] + fn command_output_uses_sanitized_last_line() { + assert_eq!( + command_output_text(b"old\n win\x1b[31mter\r\n"), + Some("win[31mter".into()) + ); + assert_eq!(command_output_text(b"\r\n"), None); + } + + #[test] + fn status_text_strips_bidi_and_zero_width_format_controls() { + assert_eq!( + sanitize_status_text("safe\u{202e}evil\u{200b}"), + Some("safeevil".into()) + ); + } + + #[test] + fn separator_preserves_printable_spacing_and_drops_controls() { + assert_eq!(sanitize_separator(" \x1b|\n "), " | "); + } +} -- cgit v1.2.3