diff --git a/Cargo.lock b/Cargo.lock index fbf3ebcfbe..bcf0294a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -692,6 +692,7 @@ dependencies = [ "serde_ignored", "serde_json", "sha2", + "time", "tokio", "toml", "tracing", @@ -1852,12 +1853,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "libc", "num-conv", "num_threads", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -1866,14 +1869,29 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tokio" version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", "tokio-macros", + "windows-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5245583508..d1af396426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,8 @@ serde = { version = "1", features = ["derive"] } serde_ignored = "0.1.14" serde_json = "1" sha2 = "0.10" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +time = { version = "0.3.47", features = ["formatting"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util"] } toml = "0.8" tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index dba64077b6..bcb3488c50 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- The desktop tab bar now has configurable right-aligned status entries for zoom state, hostname, date/time, literal text, and asynchronously refreshed command output. - Optional `keys.resize_pane_left`, `keys.resize_pane_down`, `keys.resize_pane_up`, and `keys.resize_pane_right` bindings now resize the focused pane in one keystroke without entering resize mode. - Devin CLI, Cursor Agent CLI, MastraCode, Hermes Agent, and Grok CLI integrations now install and run natively on Windows. - Panes can now route normal right-click gestures to mouse-reporting applications through the pane menu, `herdr pane input`, `pane.input.set`, or the `pane split --right-click pane` launch option. diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index a170613f0e..59ce7b2e4a 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -269,6 +269,26 @@ The sidebar is the main Herdr dashboard. Search `ui.` in the [Config reference]( Set `tab_bar_position = "bottom"` under `[ui]` to place the desktop tab row below the terminal panes. Prefix, Navigate, Copy, and Resize mode bars temporarily replace the bottom tab row while active. The default is `"top"`. +Configure an ordered tmux-style status area at the right edge of the tab row: + +```toml +[ui] +tab_bar_right = [ + { type = "zoom" }, + { type = "hostname" }, + { type = "datetime", format = "%H:%M" }, + { type = "text", text = "prod" }, + { type = "command", command = "~/.config/herdr/status.sh", interval_seconds = 5, timeout_seconds = 2 }, +] +tab_bar_right_separator = " · " +``` + +The status area is empty by default. Add `zoom` to show a fixed `ZOOM` pill while the active tab is zoomed; the existing per-tab `Z` markers remain independent. `hostname`, `datetime`, and `command` resolve on the Herdr server, so `herdr --remote` shows the remote machine's values. Datetime entries use `strftime` formatting; directives that require a UTC offset or Unix timestamp, such as `%z` and `%s`, are rejected because the value is server-local wall-clock time. + +Command entries run immediately and then at `interval_seconds` without blocking rendering or overlapping a previous run. The interval can be 1–31,536,000 seconds and the timeout can be 1–3,600 seconds. Herdr uses the last line of successful output, clears it after failure, empty output, or `timeout_seconds`, and provides the same active workspace, tab, pane, socket, binary, and working-directory context as custom command keybindings. Commands are supported on Linux, macOS, and Windows, using `/bin/sh -lc` on Linux and macOS and `cmd.exe /d /c` on Windows. + +Separators appear only between visible entries. Set `tab_bar_right_separator = ""` for direct concatenation. On a narrow tab row, the complete status area yields to the tabs and their controls. + Agent status uses compact colored dots by default. To distinguish blocked, working, done, idle, and unknown states by shape as well as color, choose **distinct symbols** in Settings or configure: ```toml diff --git a/docs/next/website/src/data/config-reference.json b/docs/next/website/src/data/config-reference.json index 2147192d51..700e28ec71 100644 --- a/docs/next/website/src/data/config-reference.json +++ b/docs/next/website/src/data/config-reference.json @@ -722,6 +722,25 @@ "bottom" ] }, + { + "key": "ui.tab_bar_right", + "type": "array", + "default": "[]", + "description": "Configure ordered right-aligned tab bar entries. Supported types are zoom, hostname, datetime, text, and command.", + "values": [ + "zoom", + "hostname", + "datetime", + "text", + "command" + ] + }, + { + "key": "ui.tab_bar_right_separator", + "type": "string", + "default": "\" \"", + "description": "Text inserted between visible right-aligned tab bar entries." + }, { "key": "ui.agent_panel_sort", "type": "enum", diff --git a/scripts/config_reference_check.py b/scripts/config_reference_check.py index f9aa5e72fd..c23b284b3c 100644 --- a/scripts/config_reference_check.py +++ b/scripts/config_reference_check.py @@ -35,7 +35,9 @@ FIELD_RE = re.compile(r"^\s*pub ([a-z_][a-z0-9_]*):\s*(.+?),?\s*$") STRUCT_RE = re.compile(r"^\s*pub(?:\(crate\))? struct ([A-Za-z0-9_]+)\s*\{\s*$") ENUM_RE = re.compile(r"^\s*pub(?:\(crate\))? enum ([A-Za-z0-9_]+)\s*\{\s*$") -VARIANT_RE = re.compile(r"^\s*([A-Z][A-Za-z0-9_]*)\s*(?:\(.*\))?\s*,?\s*$") +VARIANT_RE = re.compile( + r"^\s*([A-Z][A-Za-z0-9_]*)\s*(?:\(.*\)|\{)?\s*,?\s*$" +) RENAME_ALL_RE = re.compile(r'rename_all\s*=\s*"([^"]+)"') RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"') @@ -190,11 +192,11 @@ def parse_enum_body( index += 1 break - depth += stripped.count("{") - stripped.count("}") if depth == 0 and not stripped.startswith(("#[", "///")): match = VARIANT_RE.match(stripped) if match: variants.append(apply_rename_all(match.group(1), rename_all or "lowercase")) + depth += stripped.count("{") - stripped.count("}") index += 1 model.enums[name] = variants diff --git a/scripts/test_config_reference_check.py b/scripts/test_config_reference_check.py index 317aae8aa6..b5a804b12c 100644 --- a/scripts/test_config_reference_check.py +++ b/scripts/test_config_reference_check.py @@ -32,6 +32,8 @@ pub sidebar_width: u16, /// Host cursor policy. Default: auto. pub host_cursor: HostCursorModeConfig, + /// Tab bar status entries. + pub tab_bar_right: Vec, #[serde(rename = "accent_color")] pub accent: String, #[serde(skip)] @@ -63,6 +65,19 @@ Drawn, } +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TabBarRightEntryConfig { + Hostname, + Datetime { + format: String, + }, + Command { + command: String, + interval_seconds: u64, + }, +} + #[derive(Debug, Deserialize)] #[serde(untagged)] pub enum BindingConfig { @@ -129,6 +144,10 @@ def test_enum_values_respect_rename_all_and_untagged_enums_have_none(self) -> No self.assertEqual( entries["ui.host_cursor"]["values"], ["auto", "native-cursor", "drawn"] ) + self.assertEqual( + entries["ui.tab_bar_right"]["values"], + ["hostname", "datetime", "command"], + ) self.assertNotIn("values", entries["keys.zoom"]) diff --git a/src/app/actions.rs b/src/app/actions.rs index 20deab2af2..5d8051fd37 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 0ccd9b2162..379369aed1 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 73dc5299c0..d4d714a908 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 15f352af8f..cca530ce4a 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 31426f874d..9551055ea5 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 e460f62b79..cbecef00dc 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 0000000000..54903db667 --- /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 "), " | "); + } +} diff --git a/src/config.rs b/src/config.rs index 858ca63239..8864506148 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 9bd83a9624..16aa1d7db7 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, + /// 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 0000000000..253b1b743a --- /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 { + 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 { + 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, + } + + 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 7510ae3c61..65f1001a55 100644 --- a/src/events.rs +++ b/src/events.rs @@ -146,6 +146,12 @@ pub enum AppEvent { results: Vec, cache_updates: Vec<(std::path::PathBuf, GitStatusCacheEntry)>, }, + /// A configured tab bar status command finished. + TabBarCommandFinished { + generation: u64, + segment_index: usize, + result: Result, String>, + }, /// A plugin action or event command finished. PluginCommandFinished { log_id: String, diff --git a/src/main.rs b/src/main.rs index 02ecb8d35c..f70ee8b4f1 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 f6380df8c4..2086e2a709 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 { + None +} + +pub(crate) fn local_datetime() -> Option { + 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 { + Ok(Self) + } +} + fn raw_command_argv(command: &str, flag: &str) -> Vec { vec!["/bin/sh".into(), flag.into(), command.into()] } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 7932ae090d..89e6c96d9d 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 fdb1e2eb7b..d85da44033 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 754e2fc290..3004e6da60 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 { + let mut buffer = [0_u8; 256]; + let result = + unsafe { libc::gethostname(buffer.as_mut_ptr().cast::(), 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 { + 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(×tamp, &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, +} + +impl StatusCommandGuard { + pub(crate) fn new(child: &tokio::process::Child) -> std::io::Result { + 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 { + 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 a5b78bbe13..aef528e5b4 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 { + std::env::var("COMPUTERNAME") + .ok() + .filter(|name| !name.is_empty()) +} + +pub(crate) fn local_datetime() -> Option { + 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, ×tamp) } != 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 { + 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::()) { + 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) -> 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::()) + .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, diff --git a/src/server/headless.rs b/src/server/headless.rs index 61c825b8da..6461b13ecc 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 60143fe448..819644221f 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 02289581fa..c63fc26f8f 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> { + 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 { + 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 { 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)] @@ -438,6 +539,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();