diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-10 01:20:09 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-10 01:20:09 +0300 |
| commit | e48d83067a5a9b05dfd0a82761068866ab2d294d (patch) | |
| tree | 159b92f857f85e40037879c7f32da4957093597b /src/config | |
| parent | e2aa86a9e8b6445488ef11b2c0a8b4c22a6f79ce (diff) | |
feat: add configurable tab bar status (#2586)
* feat: show a zoom indicator in the desktop tab bar
Reserve the right edge of the tab row for a ZOOM pill while the
focused pane is zoomed, matching the accent style of the mode bars.
The per-tab Z suffix stays; the pill makes the zoomed state visible
at a glance like tmux's status-right flag.
* feat: optionally show the hostname in the desktop tab bar
Add ui.tab_bar_hostname to display the machine's hostname at the right
edge of the tab row, like tmux's #h in status-right. The value resolves
where the server renders, so remote sessions show the remote host. Off
by default.
* fix: strip control characters from the tab bar hostname
* fix: hide the hostname when it would squeeze out the tab strip
* feat: add configurable tab bar status
* fix: harden tab bar status updates
* fix: terminate tab bar status process trees
* fix: disable status commands on unsupported platforms
* fix: skip unchanged status command renders
* fix: keep tab bar status opt-in by default
---------
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Diffstat (limited to 'src/config')
| -rw-r--r-- | src/config/model.rs | 26 | ||||
| -rw-r--r-- | src/config/tab_bar.rs | 181 |
2 files changed, 205 insertions, 2 deletions
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()); + } +} |
