Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions docs/next/website/src/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions docs/next/website/src/data/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions scripts/config_reference_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*"([^"]+)"')

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions scripts/test_config_reference_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<TabBarRightEntryConfig>,
#[serde(rename = "accent_color")]
pub accent: String,
#[serde(skip)]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"])


Expand Down
3 changes: 2 additions & 1 deletion src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2935,6 +2935,7 @@ impl AppState {
}
AppEvent::WorktreeAddFinished(_) => Vec::new(),
AppEvent::WorktreeRemoveFinished(_) => Vec::new(),
AppEvent::TabBarCommandFinished { .. } => Vec::new(),
AppEvent::PluginCommandFinished { .. } => Vec::new(),
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/app/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/app/input/navigate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
55 changes: 53 additions & 2 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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)]
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/app/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading