aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs3
-rw-r--r--src/app/api.rs15
-rw-r--r--src/app/input/navigate.rs2
-rw-r--r--src/app/mod.rs55
-rw-r--r--src/app/runtime.rs2
-rw-r--r--src/app/state.rs10
-rw-r--r--src/app/tab_bar_status.rs590
-rw-r--r--src/config.rs13
-rw-r--r--src/config/model.rs26
-rw-r--r--src/config/tab_bar.rs181
-rw-r--r--src/events.rs6
-rw-r--r--src/main.rs6
-rw-r--r--src/platform/fallback.rs22
-rw-r--r--src/platform/linux.rs7
-rw-r--r--src/platform/macos.rs7
-rw-r--r--src/platform/unix_common.rs84
-rw-r--r--src/platform/windows.rs183
-rw-r--r--src/server/headless.rs2
-rw-r--r--src/ui.rs4
-rw-r--r--src/ui/tabs.rs220
20 files changed, 1414 insertions, 24 deletions
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<std::path::PathBuf>) {
+ pub(crate) fn custom_command_env(&self) -> (Vec<(String, String)>, Option<std::path::PathBuf>) {
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<Instant>,
pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>,
pub(crate) detached_custom_command_children: Vec<std::process::Child>,
+ tab_bar_status_generation: u64,
+ tab_bar_datetimes: Vec<tab_bar_status::TabBarDatetimeRuntime>,
+ tab_bar_commands: Vec<tab_bar_status::TabBarCommandRuntime>,
+ next_tab_bar_datetime_refresh: Option<Instant>,
pub(crate) persist_pane_history: bool,
pub(crate) last_render_at: Option<Instant>,
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;
@@ -2332,6 +2352,37 @@ mod tests {
}
#[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();
app.git_refresh_in_flight = true;
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<String>),
+}
+
pub struct AppState {
pub terminals:
std::collections::HashMap<crate::terminal::TerminalId, crate::terminal::TerminalState>,
@@ -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<TabBarStatusSegment>,
+ 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<tokio::task::AbortHandle>,
+}
+
+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<std::time::Instant> {
+ 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<Option<String>, 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<String> {
+ 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<String> {
+ let value: String = value
+ .chars()
+ .filter(|character| !character.is_control())
+ .collect();
+ (!value.is_empty()).then_some(value)
+}
+
+fn sanitize_status_text(value: &str) -> Option<String> {
+ 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<String> {
+ 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<Vec<u8>> {
+ 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<crate::events::AppEvent>,
+ generation: u64,
+ segment_index: usize,
+ command: String,
+ timeout: Duration,
+ environment: Vec<(String, String)>,
+ cwd: Option<std::path::PathBuf>,
+) -> 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 "), " | ");
+ }
+}
diff --git a/src/config.rs b/src/config.rs
index 858ca632..88645061 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -5,6 +5,7 @@ mod keybinds;
mod model;
mod sidebar;
mod sound;
+mod tab_bar;
mod theme;
pub use self::{
@@ -30,11 +31,20 @@ pub use self::{
SpaceSidebarToken, SpacesSidebarConfig,
},
sound::SoundConfig,
+ tab_bar::TabBarRightEntryConfig,
theme::{parse_color, CustomThemeColors, ThemeConfig, THEME_NAMES},
};
pub(crate) use self::keybinds::parse_key_combo;
-pub(crate) use self::{io::upsert_top_level_bool, theme::canonical_theme_name};
+pub(crate) use self::{
+ io::upsert_top_level_bool,
+ tab_bar::{
+ parse_tab_bar_datetime_format, tab_bar_right_diagnostics,
+ MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS, MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS,
+ MAX_TAB_BAR_RIGHT_ENTRIES,
+ },
+ theme::canonical_theme_name,
+};
pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
pub const DEFAULT_SCROLLBACK_LIMIT_BYTES: usize = 10_000_000;
@@ -74,6 +84,7 @@ impl Config {
.chain(self.remote_image_paste_key().err())
.chain(self.theme.diagnostics())
.chain(self.ui.sound.diagnostics())
+ .chain(tab_bar_right_diagnostics(&self.ui.tab_bar_right))
.chain(self.invalid_sidebar_bounds_diagnostic())
.collect()
}
diff --git a/src/config/model.rs b/src/config/model.rs
index 9bd83a96..16aa1d7d 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -5,8 +5,8 @@ use serde::{de, Deserialize, Deserializer, Serialize};
use super::{
ActionKeybinds, BindingConfig, CommandKeybindConfig, IndexedKeybind, Keybinds, SidebarConfig,
- SoundConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD, DEFAULT_MOUSE_SCROLL_LINES,
- DEFAULT_SCROLLBACK_LIMIT_BYTES,
+ SoundConfig, TabBarRightEntryConfig, ThemeConfig, DEFAULT_MOBILE_WIDTH_THRESHOLD,
+ DEFAULT_MOUSE_SCROLL_LINES, DEFAULT_SCROLLBACK_LIMIT_BYTES,
};
pub const MAX_TOAST_DELAY_SECONDS: u64 = 3600;
@@ -874,6 +874,10 @@ pub struct UiConfig {
pub hide_tab_bar_when_single_tab: bool,
/// Desktop tab row placement. Default: top.
pub tab_bar_position: TabBarPositionConfig,
+ /// Ordered entries shown at the right edge of the desktop tab row. Empty by default.
+ pub tab_bar_right: Vec<TabBarRightEntryConfig>,
+ /// Text inserted between visible right-side tab bar entries. Default: one space.
+ pub tab_bar_right_separator: 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.
@@ -1088,6 +1092,8 @@ impl Default for UiConfig {
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(),
agent_panel_sort: AgentPanelSortConfig::Spaces,
_legacy_agent_panel_scope: None,
status_indicators: StatusIndicatorStyle::Dots,
@@ -1344,6 +1350,8 @@ status_indicators = "symbols"
default_config.ui.tab_bar_position,
TabBarPositionConfig::Top
);
+ assert!(default_config.ui.tab_bar_right.is_empty());
+ assert_eq!(default_config.ui.tab_bar_right_separator, " ");
let toml = r#"
[ui]
@@ -1354,6 +1362,14 @@ pane_gaps = true
show_agent_labels_on_pane_borders = true
hide_tab_bar_when_single_tab = true
tab_bar_position = "bottom"
+tab_bar_right = [
+ { type = "zoom" },
+ { type = "hostname" },
+ { type = "datetime", format = "%H:%M" },
+ { type = "text", text = "prod" },
+ { type = "command", command = "status.sh", interval_seconds = 10, timeout_seconds = 3 },
+]
+tab_bar_right_separator = " · "
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(!config.ui.pane_borders);
@@ -1363,6 +1379,12 @@ tab_bar_position = "bottom"
assert!(config.ui.show_agent_labels_on_pane_borders);
assert!(config.ui.hide_tab_bar_when_single_tab);
assert_eq!(config.ui.tab_bar_position, TabBarPositionConfig::Bottom);
+ assert_eq!(config.ui.tab_bar_right.len(), 5);
+ assert!(matches!(
+ config.ui.tab_bar_right[1],
+ TabBarRightEntryConfig::Hostname
+ ));
+ assert_eq!(config.ui.tab_bar_right_separator, " · ");
}
#[test]
diff --git a/src/config/tab_bar.rs b/src/config/tab_bar.rs
new file mode 100644
index 00000000..253b1b74
--- /dev/null
+++ b/src/config/tab_bar.rs
@@ -0,0 +1,181 @@
+use serde::{Deserialize, Serialize};
+
+pub(crate) const DEFAULT_TAB_BAR_COMMAND_INTERVAL_SECONDS: u64 = 5;
+pub(crate) const DEFAULT_TAB_BAR_COMMAND_TIMEOUT_SECONDS: u64 = 2;
+pub(crate) const MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS: u64 = 31_536_000;
+pub(crate) const MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS: u64 = 3_600;
+pub(crate) const MAX_TAB_BAR_RIGHT_ENTRIES: usize = 16;
+
+fn default_datetime_format() -> String {
+ "%H:%M".to_string()
+}
+
+fn default_command_interval_seconds() -> u64 {
+ DEFAULT_TAB_BAR_COMMAND_INTERVAL_SECONDS
+}
+
+fn default_command_timeout_seconds() -> u64 {
+ DEFAULT_TAB_BAR_COMMAND_TIMEOUT_SECONDS
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
+#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
+pub enum TabBarRightEntryConfig {
+ Zoom,
+ Hostname,
+ Datetime {
+ #[serde(default = "default_datetime_format")]
+ format: String,
+ },
+ Text {
+ text: String,
+ },
+ Command {
+ command: String,
+ #[serde(default = "default_command_interval_seconds")]
+ interval_seconds: u64,
+ #[serde(default = "default_command_timeout_seconds")]
+ timeout_seconds: u64,
+ },
+}
+
+pub(crate) fn parse_tab_bar_datetime_format(
+ value: &str,
+) -> Result<time::format_description::OwnedFormatItem, String> {
+ if value.is_empty() {
+ return Err("datetime format is empty".into());
+ }
+ let format = time::format_description::parse_strftime_owned(value)
+ .map_err(|err| format!("invalid datetime format: {err}"))?;
+ time::PrimitiveDateTime::MIN
+ .format(&format)
+ .map_err(|err| format!("unsupported datetime format: {err}"))?;
+ Ok(format)
+}
+
+pub(crate) fn tab_bar_right_diagnostics(entries: &[TabBarRightEntryConfig]) -> Vec<String> {
+ let mut diagnostics = Vec::new();
+ if entries.len() > MAX_TAB_BAR_RIGHT_ENTRIES {
+ diagnostics.push(format!(
+ "ui.tab_bar_right may contain at most {MAX_TAB_BAR_RIGHT_ENTRIES} entries; ignoring extras"
+ ));
+ }
+
+ for (index, entry) in entries.iter().enumerate().take(MAX_TAB_BAR_RIGHT_ENTRIES) {
+ match entry {
+ TabBarRightEntryConfig::Datetime { format } => {
+ if format.is_empty() {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] datetime format is empty; hiding entry"
+ ));
+ } else if let Err(err) = parse_tab_bar_datetime_format(format) {
+ diagnostics.push(format!("ui.tab_bar_right[{index}] has {err}; hiding entry"));
+ }
+ }
+ TabBarRightEntryConfig::Command {
+ command,
+ interval_seconds,
+ timeout_seconds,
+ } => {
+ if command.trim().is_empty() {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] command is empty; hiding entry"
+ ));
+ }
+ if *interval_seconds == 0 {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] interval_seconds must be at least 1; hiding entry"
+ ));
+ }
+ if *interval_seconds > MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] interval_seconds may be at most {MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS}; hiding entry"
+ ));
+ }
+ if *timeout_seconds == 0 {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] timeout_seconds must be at least 1; hiding entry"
+ ));
+ }
+ if *timeout_seconds > MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS {
+ diagnostics.push(format!(
+ "ui.tab_bar_right[{index}] timeout_seconds may be at most {MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS}; hiding entry"
+ ));
+ }
+ }
+ TabBarRightEntryConfig::Zoom
+ | TabBarRightEntryConfig::Hostname
+ | TabBarRightEntryConfig::Text { .. } => {}
+ }
+ }
+
+ diagnostics
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn tab_bar_entries_parse_with_command_defaults() {
+ #[derive(Deserialize)]
+ struct Wrapper {
+ entries: Vec<TabBarRightEntryConfig>,
+ }
+
+ let parsed: Wrapper = toml::from_str(
+ r#"
+entries = [
+ { type = "zoom" },
+ { type = "hostname" },
+ { type = "datetime", format = "%H:%M" },
+ { type = "text", text = "prod" },
+ { type = "command", command = "status.sh" },
+]
+"#,
+ )
+ .expect("parse tab bar entries");
+
+ assert_eq!(parsed.entries.len(), 5);
+ assert!(matches!(
+ &parsed.entries[4],
+ TabBarRightEntryConfig::Command {
+ interval_seconds: DEFAULT_TAB_BAR_COMMAND_INTERVAL_SECONDS,
+ timeout_seconds: DEFAULT_TAB_BAR_COMMAND_TIMEOUT_SECONDS,
+ ..
+ }
+ ));
+ }
+
+ #[test]
+ fn diagnostics_reject_invalid_datetime_and_command_schedules() {
+ let entries = vec![
+ TabBarRightEntryConfig::Datetime {
+ format: "%Q".into(),
+ },
+ TabBarRightEntryConfig::Datetime {
+ format: "%z".into(),
+ },
+ TabBarRightEntryConfig::Command {
+ command: String::new(),
+ interval_seconds: 0,
+ timeout_seconds: 0,
+ },
+ TabBarRightEntryConfig::Command {
+ command: "status.sh".into(),
+ interval_seconds: MAX_TAB_BAR_COMMAND_INTERVAL_SECONDS + 1,
+ timeout_seconds: MAX_TAB_BAR_COMMAND_TIMEOUT_SECONDS + 1,
+ },
+ ];
+
+ let diagnostics = tab_bar_right_diagnostics(&entries).join("\n");
+ assert!(diagnostics.contains("invalid datetime format"));
+ assert!(diagnostics.contains("unsupported datetime format"));
+ assert!(diagnostics.contains("command is empty"));
+ assert!(diagnostics.contains("interval_seconds must be at least 1"));
+ assert!(diagnostics.contains("interval_seconds may be at most"));
+ assert!(diagnostics.contains("timeout_seconds must be at least 1"));
+ assert!(diagnostics.contains("timeout_seconds may be at most"));
+ assert!(parse_tab_bar_datetime_format("").is_err());
+ }
+}
diff --git a/src/events.rs b/src/events.rs
index 7510ae3c..65f1001a 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -146,6 +146,12 @@ pub enum AppEvent {
results: Vec<WorkspaceGitStatus>,
cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>,
},
+ /// A configured tab bar status command finished.
+ TabBarCommandFinished {
+ generation: u64,
+ segment_index: usize,
+ result: Result<Option<String>, String>,
+ },
/// A plugin action or event command finished.
PluginCommandFinished {
log_id: String,
diff --git a/src/main.rs b/src/main.rs
index 02ecb8d3..f70ee8b4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -332,6 +332,12 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Desktop tab row placement: "top" or "bottom".
# tab_bar_position = "top"
+# Ordered status entries at the right edge of the desktop tab bar.
+# Supported types: zoom, hostname, datetime, text, and command.
+# Hostname, datetime, and command entries resolve on the Herdr server.
+# tab_bar_right = []
+# tab_bar_right_separator = " "
+
# 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/platform/fallback.rs b/src/platform/fallback.rs
index f6380df8..2086e2a7 100644
--- a/src/platform/fallback.rs
+++ b/src/platform/fallback.rs
@@ -89,6 +89,28 @@ pub(crate) fn should_draw_host_cursor_by_default() -> bool {
false
}
+pub(crate) fn hostname() -> Option<String> {
+ None
+}
+
+pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> {
+ None
+}
+
+pub(crate) fn status_commands_supported() -> bool {
+ false
+}
+
+pub(crate) fn configure_status_command(_process: &mut std::process::Command) {}
+
+pub(crate) struct StatusCommandGuard;
+
+impl StatusCommandGuard {
+ pub(crate) fn new(_child: &tokio::process::Child) -> std::io::Result<Self> {
+ Ok(Self)
+ }
+}
+
fn raw_command_argv(command: &str, flag: &str) -> Vec<std::ffi::OsString> {
vec!["/bin/sh".into(), flag.into(), command.into()]
}
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 7932ae09..89e6c96d 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -13,9 +13,10 @@ use super::{
};
pub(crate) use super::unix_common::{
- create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file,
- remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument,
- remote_reattach_program, remote_ssh_config_paths,
+ configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir,
+ create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path,
+ remote_private_temp_base, remote_reattach_argument, remote_reattach_program,
+ remote_ssh_config_paths, status_commands_supported, StatusCommandGuard,
};
const WSL_MARKER_ENV_VARS: &[&str] = &["WSL_DISTRO_NAME", "WSL_INTEROP"];
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index fdb1e2eb..d85da440 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -13,9 +13,10 @@ use super::{
};
pub(crate) use super::unix_common::{
- create_remote_private_dir, create_remote_ssh_config_dir, create_remote_ssh_config_file,
- remote_bridge_endpoint_path, remote_private_temp_base, remote_reattach_argument,
- remote_reattach_program, remote_ssh_config_paths,
+ configure_status_command, create_remote_private_dir, create_remote_ssh_config_dir,
+ create_remote_ssh_config_file, hostname, local_datetime, remote_bridge_endpoint_path,
+ remote_private_temp_base, remote_reattach_argument, remote_reattach_program,
+ remote_ssh_config_paths, status_commands_supported, StatusCommandGuard,
};
const PROC_PGRP_ONLY: u32 = 2;
diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs
index 754e2fc2..3004e6da 100644
--- a/src/platform/unix_common.rs
+++ b/src/platform/unix_common.rs
@@ -119,6 +119,90 @@ fn fits_unix_socket_path(path: &Path) -> bool {
path.as_os_str().as_bytes().len() <= 103
}
+/// The machine's node name, as shown by tmux's `#h`.
+pub(crate) fn hostname() -> Option<String> {
+ let mut buffer = [0_u8; 256];
+ let result =
+ unsafe { libc::gethostname(buffer.as_mut_ptr().cast::<libc::c_char>(), buffer.len()) };
+ if result != 0 {
+ return None;
+ }
+ let end = buffer
+ .iter()
+ .position(|&byte| byte == 0)
+ .unwrap_or(buffer.len());
+ let name = String::from_utf8_lossy(&buffer[..end]).into_owned();
+ (!name.is_empty()).then_some(name)
+}
+
+pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> {
+ let mut timestamp: libc::time_t = 0;
+ if unsafe { libc::time(&mut timestamp) } == -1 {
+ return None;
+ }
+ let mut local: libc::tm = unsafe { std::mem::zeroed() };
+ if unsafe { libc::localtime_r(&timestamp, &mut local) }.is_null() {
+ return None;
+ }
+ datetime_from_tm(&local)
+}
+
+pub(crate) fn status_commands_supported() -> bool {
+ true
+}
+
+pub(crate) fn configure_status_command(process: &mut std::process::Command) {
+ use std::os::unix::process::CommandExt;
+
+ process.process_group(0);
+}
+
+pub(crate) struct StatusCommandGuard {
+ process_group_id: Option<i32>,
+}
+
+impl StatusCommandGuard {
+ pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result<Self> {
+ let process_id = child
+ .id()
+ .ok_or_else(|| std::io::Error::other("status command has no process id"))?;
+ let process_group_id = i32::try_from(process_id)
+ .map_err(|_| std::io::Error::other("status command process id exceeds i32"))?;
+ Ok(Self {
+ process_group_id: Some(process_group_id),
+ })
+ }
+}
+
+impl Drop for StatusCommandGuard {
+ fn drop(&mut self) {
+ if let Some(process_group_id) = self.process_group_id.take() {
+ // The command was spawned as this process group's leader. Killing the
+ // group also cleans up background descendants on completion/cancellation.
+ unsafe {
+ libc::kill(-process_group_id, libc::SIGKILL);
+ }
+ }
+ }
+}
+
+fn datetime_from_tm(value: &libc::tm) -> Option<time::PrimitiveDateTime> {
+ let month = time::Month::try_from(u8::try_from(value.tm_mon + 1).ok()?).ok()?;
+ let date = time::Date::from_calendar_date(
+ value.tm_year + 1900,
+ month,
+ u8::try_from(value.tm_mday).ok()?,
+ )
+ .ok()?;
+ let time = time::Time::from_hms(
+ u8::try_from(value.tm_hour).ok()?,
+ u8::try_from(value.tm_min).ok()?,
+ u8::try_from(value.tm_sec).ok()?,
+ )
+ .ok()?;
+ Some(time::PrimitiveDateTime::new(date, time))
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/platform/windows.rs b/src/platform/windows.rs
index a5b78bbe..aef528e5 100644
--- a/src/platform/windows.rs
+++ b/src/platform/windows.rs
@@ -33,13 +33,16 @@ use windows_sys::{
Diagnostics::{
Debug::ReadProcessMemory,
ToolHelp::{
- CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
- TH32CS_SNAPPROCESS,
+ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, Thread32First,
+ Thread32Next, PROCESSENTRY32W, TH32CS_SNAPPROCESS, TH32CS_SNAPTHREAD,
+ THREADENTRY32,
},
},
JobObjects::{
- IsProcessInJob, JobObjectExtendedLimitInformation, QueryInformationJobObject,
- JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
+ AssignProcessToJobObject, CreateJobObjectW, IsProcessInJob,
+ JobObjectExtendedLimitInformation, QueryInformationJobObject,
+ SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
},
Memory::{
GlobalAlloc, GlobalLock, GlobalSize, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE,
@@ -47,10 +50,11 @@ use windows_sys::{
},
Ole::{CF_DIB, CF_DIBV5, CF_UNICODETEXT},
Threading::{
- GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess,
- QueryFullProcessImageNameW, TerminateProcess, CREATE_NO_WINDOW, DETACHED_PROCESS,
- PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION,
- PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
+ GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess, OpenThread,
+ QueryFullProcessImageNameW, ResumeThread, TerminateProcess, CREATE_NO_WINDOW,
+ CREATE_SUSPENDED, DETACHED_PROCESS, PROCESS_BASIC_INFORMATION,
+ PROCESS_QUERY_INFORMATION, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
+ THREAD_SUSPEND_RESUME,
},
},
UI::{
@@ -263,6 +267,38 @@ pub(crate) fn should_draw_host_cursor_by_default() -> bool {
true
}
+/// The machine's node name, as shown by tmux's `#h`.
+pub(crate) fn hostname() -> Option<String> {
+ std::env::var("COMPUTERNAME")
+ .ok()
+ .filter(|name| !name.is_empty())
+}
+
+pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> {
+ let mut timestamp: libc::time_t = 0;
+ if unsafe { libc::time(&mut timestamp) } == -1 {
+ return None;
+ }
+ let mut local: libc::tm = unsafe { std::mem::zeroed() };
+ if unsafe { libc::localtime_s(&mut local, &timestamp) } != 0 {
+ return None;
+ }
+ let month = time::Month::try_from(u8::try_from(local.tm_mon + 1).ok()?).ok()?;
+ let date = time::Date::from_calendar_date(
+ local.tm_year + 1900,
+ month,
+ u8::try_from(local.tm_mday).ok()?,
+ )
+ .ok()?;
+ let time = time::Time::from_hms(
+ u8::try_from(local.tm_hour).ok()?,
+ u8::try_from(local.tm_min).ok()?,
+ u8::try_from(local.tm_sec).ok()?,
+ )
+ .ok()?;
+ Some(time::PrimitiveDateTime::new(date, time))
+}
+
#[derive(Debug, Clone, PartialEq, Eq)]
struct WindowsProcessEntry {
pid: u32,
@@ -394,6 +430,137 @@ pub(crate) fn detached_custom_command_process_platform(command: &str) -> std::pr
detached_custom_command_process_with_comspec(command, std::env::var_os("ComSpec"))
}
+pub(crate) fn status_commands_supported() -> bool {
+ true
+}
+
+pub(crate) fn configure_status_command(process: &mut std::process::Command) {
+ use std::os::windows::process::CommandExt;
+
+ // The process must not run before it is assigned to the kill-on-close job.
+ process.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED);
+}
+
+pub(crate) struct StatusCommandGuard {
+ job: usize,
+}
+
+impl StatusCommandGuard {
+ pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result<Self> {
+ let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
+ if job.is_null() {
+ return Err(std::io::Error::last_os_error());
+ }
+
+ let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
+ limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
+ let limits_size = match u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>()) {
+ Ok(size) => size,
+ Err(_) => {
+ unsafe {
+ CloseHandle(job);
+ }
+ return Err(std::io::Error::other("job limits size exceeds u32"));
+ }
+ };
+ if unsafe {
+ SetInformationJobObject(
+ job,
+ JobObjectExtendedLimitInformation,
+ std::ptr::from_ref(&limits).cast(),
+ limits_size,
+ )
+ } == 0
+ {
+ let error = std::io::Error::last_os_error();
+ unsafe {
+ CloseHandle(job);
+ }
+ return Err(error);
+ }
+
+ let Some(process) = child.raw_handle() else {
+ unsafe {
+ CloseHandle(job);
+ }
+ return Err(std::io::Error::other(
+ "status command has no process handle",
+ ));
+ };
+ if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 {
+ let error = std::io::Error::last_os_error();
+ unsafe {
+ CloseHandle(job);
+ }
+ return Err(error);
+ }
+ if let Err(error) = resume_suspended_process(child.id()) {
+ unsafe {
+ CloseHandle(job);
+ }
+ return Err(error);
+ }
+
+ Ok(Self { job: job as usize })
+ }
+}
+
+fn resume_suspended_process(process_id: Option<u32>) -> std::io::Result<()> {
+ let process_id =
+ process_id.ok_or_else(|| std::io::Error::other("status command has no process id"))?;
+ let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
+ if snapshot == INVALID_HANDLE_VALUE {
+ return Err(std::io::Error::last_os_error());
+ }
+
+ let result = (|| {
+ let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() };
+ entry.dwSize = u32::try_from(size_of::<THREADENTRY32>())
+ .map_err(|_| std::io::Error::other("thread entry size exceeds u32"))?;
+ if unsafe { Thread32First(snapshot, &mut entry) } == 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+
+ loop {
+ if entry.th32OwnerProcessID == process_id {
+ let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
+ if thread.is_null() {
+ return Err(std::io::Error::last_os_error());
+ }
+ let resume_result = unsafe { ResumeThread(thread) };
+ let resume_error = (resume_result == u32::MAX).then(std::io::Error::last_os_error);
+ unsafe {
+ CloseHandle(thread);
+ }
+ if let Some(error) = resume_error {
+ return Err(error);
+ }
+ return Ok(());
+ }
+ if unsafe { Thread32Next(snapshot, &mut entry) } == 0 {
+ return Err(std::io::Error::other(
+ "status command primary thread was not found",
+ ));
+ }
+ }
+ })();
+
+ unsafe {
+ CloseHandle(snapshot);
+ }
+ result
+}
+
+impl Drop for StatusCommandGuard {
+ fn drop(&mut self) {
+ // KILL_ON_JOB_CLOSE terminates the shell and every descendant still in
+ // the job, including on task cancellation and config reload.
+ unsafe {
+ CloseHandle(self.job as HANDLE);
+ }
+ }
+}
+
fn detached_custom_command_process_with_comspec(
command: &str,
comspec: Option<std::ffi::OsString>,
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 61c825b8..6461b13e 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -4554,6 +4554,8 @@ impl HeadlessServer {
changed = true;
}
+ changed |= self.app.handle_tab_bar_status_tasks(now);
+
if geometry_dirty {
self.app.pending_agent_resume_deadline = None;
} else {
diff --git a/src/ui.rs b/src/ui.rs
index 60143fe4..81964422 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -96,7 +96,7 @@ pub(crate) use self::{
},
panes::{apply_pane_chrome, pane_inner_rect, pane_is_scrolled_back},
tab_surface::{tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceView},
- tabs::compute_tab_bar_view,
+ tabs::{compute_tab_bar_view, tab_bar_content_area},
widgets::{centered_popup_rect, modal_stack_areas},
};
use crate::app::state::ViewLayout;
@@ -267,7 +267,7 @@ fn compute_view_internal(
.map(|ws| {
compute_tab_bar_view(
ws,
- tab_bar_rect,
+ tab_bar_content_area(app, tab_bar_rect),
app.tab_scroll,
app.tab_scroll_follow_active,
app.mouse_capture,
diff --git a/src/ui/tabs.rs b/src/ui/tabs.rs
index 02289581..c63fc26f 100644
--- a/src/ui/tabs.rs
+++ b/src/ui/tabs.rs
@@ -12,6 +12,11 @@ use crate::app::AppState;
const MIN_TAB_WIDTH: u16 = 8;
const NEW_TAB_WIDTH: u16 = 3;
const TAB_SCROLL_BUTTON_WIDTH: u16 = 3;
+const ZOOM_INDICATOR: &str = "ZOOM";
+// The narrowest overflowing tab strip worth keeping interactive: one
+// minimum-width tab, both scroll controls, and the new-tab control.
+const MIN_TAB_STRIP_WIDTH: u16 =
+ MIN_TAB_WIDTH + NEW_TAB_WIDTH + TAB_SCROLL_BUTTON_WIDTH.saturating_mul(2);
#[derive(Debug, Clone, Default)]
pub(crate) struct TabBarView {
@@ -39,6 +44,70 @@ fn tab_chrome_label(ws: &crate::workspace::Workspace, tab_idx: usize) -> String
}
}
+#[derive(Clone, Copy)]
+struct VisibleStatusSegment<'a> {
+ text: &'a str,
+ accent: bool,
+}
+
+fn visible_status_segments(app: &AppState) -> Vec<VisibleStatusSegment<'_>> {
+ let zoomed = app
+ .active
+ .and_then(|index| app.workspaces.get(index))
+ .is_some_and(|workspace| workspace.zoomed);
+ app.tab_bar_right
+ .iter()
+ .filter_map(|segment| match segment {
+ crate::app::state::TabBarStatusSegment::Zoom if zoomed => Some(VisibleStatusSegment {
+ text: ZOOM_INDICATOR,
+ accent: true,
+ }),
+ crate::app::state::TabBarStatusSegment::Text(Some(text))
+ if display_width_u16(text) > 0 =>
+ {
+ Some(VisibleStatusSegment {
+ text,
+ accent: false,
+ })
+ }
+ crate::app::state::TabBarStatusSegment::Zoom
+ | crate::app::state::TabBarStatusSegment::Text(_) => None,
+ })
+ .collect()
+}
+
+fn tab_bar_status_width(app: &AppState) -> u16 {
+ let segments = visible_status_segments(app);
+ let content_width = segments.iter().fold(0_u16, |width, segment| {
+ width.saturating_add(display_width_u16(segment.text))
+ });
+ let separators = u16::try_from(segments.len().saturating_sub(1)).unwrap_or(u16::MAX);
+ content_width
+ .saturating_add(display_width_u16(&app.tab_bar_right_separator).saturating_mul(separators))
+}
+
+fn tab_bar_status_area(app: &AppState, area: Rect) -> Option<Rect> {
+ let width = tab_bar_status_width(app);
+ if width == 0 {
+ return None;
+ }
+ let reserved = width.saturating_add(1);
+ (area.width.saturating_sub(reserved) >= MIN_TAB_STRIP_WIDTH)
+ .then(|| Rect::new(area.x + area.width.saturating_sub(width), area.y, width, 1))
+}
+
+// Tabs win over status decoration on narrow rows. The extra reserved cell is
+// the gap between the interactive strip and the right-aligned status entries.
+pub(crate) fn tab_bar_content_area(app: &AppState, area: Rect) -> Rect {
+ let reserved = tab_bar_status_area(app, area)
+ .map(|status| status.width.saturating_add(1))
+ .unwrap_or(0);
+ Rect {
+ width: area.width.saturating_sub(reserved),
+ ..area
+ }
+}
+
fn layout_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect, scroll: usize) -> Vec<Rect> {
let mut rects = vec![Rect::default(); ws.tabs.len()];
if area.width == 0 || area.height == 0 {
@@ -380,10 +449,12 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
}
}
if last_visible_idx.is_some_and(|idx| idx + 1 < ws.tabs.len()) {
+ let content = tab_bar_content_area(app, area);
+ let content_right = content.x + content.width;
let x = if app.mouse_capture && app.view.tab_scroll_right_hit_area.width > 0 {
app.view.tab_scroll_right_hit_area.x.saturating_sub(1)
} else {
- area.x + area.width.saturating_sub(1)
+ content_right.saturating_sub(1)
};
if x >= area.x && x < area.x + area.width {
frame.buffer_mut()[(x, area.y)]
@@ -391,6 +462,36 @@ pub(super) fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
.set_style(Style::default().fg(p.overlay0));
}
}
+
+ if let Some(status_area) = tab_bar_status_area(app, area) {
+ let segments = visible_status_segments(app);
+ let separator_width = display_width_u16(&app.tab_bar_right_separator);
+ let mut x = status_area.x;
+ for (index, segment) in segments.iter().enumerate() {
+ if index > 0 && separator_width > 0 {
+ let rect = Rect::new(x, area.y, separator_width, 1);
+ frame.render_widget(
+ Paragraph::new(app.tab_bar_right_separator.as_str())
+ .style(Style::default().fg(p.overlay0).bg(p.panel_bg)),
+ rect,
+ );
+ x = x.saturating_add(separator_width);
+ }
+
+ let width = display_width_u16(segment.text);
+ let rect = Rect::new(x, area.y, width, 1);
+ let style = if segment.accent {
+ Style::default()
+ .fg(panel_contrast_fg(p))
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.overlay1).bg(p.panel_bg)
+ };
+ frame.render_widget(Paragraph::new(segment.text).style(style), rect);
+ x = x.saturating_add(width);
+ }
+ }
}
#[cfg(test)]
@@ -439,6 +540,123 @@ mod tests {
}
#[test]
+ fn tab_bar_renders_ordered_status_entries_with_separator() {
+ let mut app = AppState::test_new();
+ let mut ws = Workspace::test_new("test");
+ ws.tabs[0].zoomed = true;
+ app.tab_bar_right = vec![
+ crate::app::state::TabBarStatusSegment::Zoom,
+ crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())),
+ crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())),
+ ];
+ app.tab_bar_right_separator = " · ".into();
+
+ app.workspaces = vec![ws];
+ app.active = Some(0);
+ app.view.tab_bar_rect = Rect::new(0, 0, 60, 1);
+ let content = tab_bar_content_area(&app, app.view.tab_bar_rect);
+ let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false);
+ app.view.tab_hit_areas = view.tab_hit_areas.clone();
+
+ let backend = TestBackend::new(60, 1);
+ let mut terminal = Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
+ .unwrap();
+
+ let buffer = terminal.backend().buffer();
+ let row = buffer_row_text(buffer, app.view.tab_bar_rect, 0);
+ assert!(
+ row.ends_with("ZOOM · wintermute · 14:30"),
+ "tab row: {row:?}"
+ );
+ let status_x = 60 - display_width_u16("ZOOM · wintermute · 14:30");
+ assert_eq!(buffer[(status_x, 0)].style().bg, Some(app.palette.accent));
+ for rect in &view.tab_hit_areas {
+ assert!(rect.x + rect.width <= content.x + content.width);
+ }
+ }
+
+ #[test]
+ fn hidden_status_entries_do_not_leave_dangling_separators() {
+ let mut app = AppState::test_new();
+ app.tab_bar_right = vec![
+ crate::app::state::TabBarStatusSegment::Zoom,
+ crate::app::state::TabBarStatusSegment::Text(None),
+ crate::app::state::TabBarStatusSegment::Text(Some("wintermute".into())),
+ ];
+ app.tab_bar_right_separator = " | ".into();
+ app.workspaces = vec![Workspace::test_new("test")];
+ app.active = Some(0);
+ app.view.tab_bar_rect = Rect::new(0, 0, 40, 1);
+ let content = tab_bar_content_area(&app, app.view.tab_bar_rect);
+ let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, false);
+ app.view.tab_hit_areas = view.tab_hit_areas;
+
+ let backend = TestBackend::new(40, 1);
+ let mut terminal = Terminal::new(backend).unwrap();
+ terminal
+ .draw(|frame| render_tab_bar(&app, frame, app.view.tab_bar_rect))
+ .unwrap();
+
+ let row = buffer_row_text(terminal.backend().buffer(), app.view.tab_bar_rect, 0);
+ assert!(row.ends_with("wintermute"), "tab row: {row:?}");
+ assert!(!row.contains(" | "), "tab row: {row:?}");
+ }
+
+ #[test]
+ fn status_reservation_keeps_a_minimum_width_tab_between_scroll_controls() {
+ let mut app = AppState::test_new();
+ app.tab_bar_right = vec![crate::app::state::TabBarStatusSegment::Text(Some(
+ "x".into(),
+ ))];
+ let mut workspace = Workspace::test_new("test");
+ workspace.test_add_tab(None);
+ workspace.test_add_tab(None);
+ app.workspaces = vec![workspace];
+ app.active = Some(0);
+
+ let too_narrow = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 1, 1);
+ assert_eq!(tab_bar_content_area(&app, too_narrow), too_narrow);
+
+ let wide_enough = Rect::new(0, 0, MIN_TAB_STRIP_WIDTH + 2, 1);
+ let content = tab_bar_content_area(&app, wide_enough);
+ assert_eq!(content.width, MIN_TAB_STRIP_WIDTH);
+ let view = compute_tab_bar_view(&app.workspaces[0], content, 0, true, true);
+ assert!(view.tab_hit_areas[0].width >= MIN_TAB_WIDTH);
+ }
+
+ #[test]
+ fn combined_status_entries_yield_to_tab_controls_on_narrow_rows() {
+ let mut app = AppState::test_new();
+ app.tab_bar_right = vec![
+ crate::app::state::TabBarStatusSegment::Text(Some(
+ "a-hostname-wider-than-the-whole-bar".into(),
+ )),
+ crate::app::state::TabBarStatusSegment::Text(Some("14:30".into())),
+ ];
+ app.workspaces = vec![Workspace::test_new("test")];
+ app.active = Some(0);
+ app.view.tab_bar_rect = Rect::new(0, 0, 30, 1);
+
+ assert_eq!(
+ tab_bar_content_area(&app, app.view.tab_bar_rect),
+ app.view.tab_bar_rect
+ );
+ assert_eq!(tab_bar_status_area(&app, app.view.tab_bar_rect), None);
+
+ let view = compute_tab_bar_view(
+ &app.workspaces[0],
+ tab_bar_content_area(&app, app.view.tab_bar_rect),
+ 0,
+ true,
+ true,
+ );
+ assert!(view.tab_hit_areas[0].width > 0);
+ assert!(view.new_tab_hit_area.width > 0);
+ }
+
+ #[test]
fn active_auto_named_tab_keeps_readable_weight() {
let mut app = AppState::test_new();
let ws = Workspace::test_new("test");