Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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 default contains only `zoom`, which appears while the active tab is zoomed. `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 use the platform shell: `/bin/sh -lc` on Unix 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": "[{ type = \"zoom\" }]",
"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
18 changes: 18 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,
/// Status entry.
pub status: StatusConfig,
#[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 StatusConfig {
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,9 @@ 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.status"]["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
10 changes: 10 additions & 0 deletions src/app/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,16 @@ impl App {
return;
}

if let AppEvent::TabBarCommandFinished {
generation,
segment_index,
result,
} = ev
{
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
24 changes: 22 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
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
10 changes: 10 additions & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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![TabBarStatusSegment::Zoom],
tab_bar_right_separator: " ".into(),
pane_history_persistence: false,
reveal_hidden_cursor_for_cjk_ime: false,
cjk_ime_agent_filter_configured: false,
Expand Down
Loading
Loading