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>
This commit is contained in:
Can Celik 2026-08-10 01:20:09 +03:00 committed by GitHub
parent e2aa86a9e8
commit e48d83067a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1497 additions and 27 deletions

18
Cargo.lock generated
View File

@ -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]]

View File

@ -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"] }

View File

@ -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.

View File

@ -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 131,536,000 seconds and the timeout can be 13,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

View File

@ -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",

View File

@ -35,7 +35,9 @@ SKIPPED_SUBTREES = ("keys.command",)
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

View File

@ -32,6 +32,8 @@ pub struct UiConfig {
pub sidebar_width: u16,
/// Host cursor policy. Default: auto.
pub host_cursor: HostCursorModeConfig,
/// Tab bar status entries.
pub tab_bar_right: Vec<TabBarRightEntryConfig>,
#[serde(rename = "accent_color")]
pub accent: String,
#[serde(skip)]
@ -63,6 +65,19 @@ pub enum HostCursorModeConfig {
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 @@ class CollectKeysTests(unittest.TestCase):
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"])

View File

@ -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(),
}
}

View File

@ -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,

View File

@ -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(),

View File

@ -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;
@ -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();

View File

@ -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()

View File

@ -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,

590
src/app/tab_bar_status.rs Normal file
View File

@ -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 "), " | ");
}
}

View File

@ -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()
}

View File

@ -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]

181
src/config/tab_bar.rs Normal file
View File

@ -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());
}
}

View File

@ -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,

View File

@ -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"

View File

@ -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()]
}

View File

@ -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"];

View File

@ -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;

View File

@ -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::*;

View File

@ -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>,

View File

@ -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 {

View File

@ -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,

View File

@ -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)]
@ -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();