aboutsummaryrefslogtreecommitdiff
path: root/src/config
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-10 01:20:09 +0300
committerGitHub <noreply@github.com>2026-08-10 01:20:09 +0300
commite48d83067a5a9b05dfd0a82761068866ab2d294d (patch)
tree159b92f857f85e40037879c7f32da4957093597b /src/config
parente2aa86a9e8b6445488ef11b2c0a8b4c22a6f79ce (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.rs26
-rw-r--r--src/config/tab_bar.rs181
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());
+ }
+}