Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added
- CLI help now points coding agents to Herdr's plain-text guide, documentation index, and built-in control skill.
- Added Qwen Code detection for idle, working, and user-confirmation states, plus optional native session restore.
- Local clients now refresh a configurable allowlist of desktop and authentication environment variables when they attach, so processes launched afterward can use the current Wayland, X11, and SSH session environment. (#2448)
- Herdr now keeps the outer terminal window title in sync with the session through `ui.window_title`, so window managers and terminal tab bars show the active workspace and the host the panes actually run on.
- 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.move_tab_previous` and `keys.move_tab_next` bindings now reorder the active tab in place, wrapping at either end.
Expand Down
13 changes: 13 additions & 0 deletions docs/next/website/src/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,19 @@ resume_agents_on_restore = true

Only panes with a valid native session reference from an official integration can resume; other panes restore as normal shells. See [Session state and restore](/docs/session-state/) for supported Agents and persistence behavior.

## Environment refresh on attach

When a local client attaches, Herdr refreshes a tmux-style allowlist of desktop and authentication variables for processes launched afterward. Running processes keep their existing environment. This lets a server started outside a graphical session launch new panes with current values such as `WAYLAND_DISPLAY`, `DISPLAY`, and `SSH_AUTH_SOCK`.

Customize the allowlist under `[session]`:

```toml
[session]
update_environment = ["WAYLAND_DISPLAY", "DISPLAY", "SSH_AUTH_SOCK", "HYPRLAND_INSTANCE_SIGNATURE"]
```

Missing variables are removed from future process environments. Remote clients do not update the remote server from the local environment. `TERM`, `COLORTERM`, `CODEX_THREAD_ID`, and `HERDR_*` variables are always managed by Herdr and cannot be added to this list.

## IME cursor tracking

On macOS, AI Agent TUIs that hide the hardware cursor can prevent native input-method candidate windows from following the focused pane. Reveal a cursor anchor for those panes with:
Expand Down
6 changes: 6 additions & 0 deletions docs/next/website/src/data/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,12 @@
"type": "boolean",
"default": "true",
"description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session."
},
{
"key": "session.update_environment",
"type": "array of strings",
"default": "[\"DISPLAY\", \"KRB5CCNAME\", \"MSYSTEM\", \"SSH_ASKPASS\", \"SSH_AUTH_SOCK\", \"SSH_AGENT_PID\", \"SSH_CONNECTION\", \"WAYLAND_DISPLAY\", \"WINDOWID\", \"XAUTHORITY\", \"XDG_CURRENT_DESKTOP\", \"XDG_SESSION_DESKTOP\", \"XDG_SESSION_TYPE\"]",
"description": "Refresh selected environment variables from each local client for processes launched after it attaches. Running processes are unchanged."
}
]
},
Expand Down
14 changes: 13 additions & 1 deletion src/app/api/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -928,7 +928,7 @@ impl App {
.map(|terminal| terminal.cwd.clone())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let moved_pane_id = moved.pane_id;
let workspace = crate::workspace::Workspace::from_existing_pane(
let mut workspace = crate::workspace::Workspace::from_existing_pane(
label,
tab_label,
identity_cwd,
Expand All @@ -937,6 +937,7 @@ impl App {
self.render_notify.clone(),
self.render_dirty.clone(),
);
workspace.set_session_environment(&self.session_environment);
self.state.workspaces.push(workspace);
let target_ws_idx = self.state.workspaces.len() - 1;
created_workspace = true;
Expand Down Expand Up @@ -1068,6 +1069,7 @@ impl App {
);
workspace.id = context.previous_workspace_id;
workspace.worktree_space = context.previous_worktree_space;
workspace.set_session_environment(&self.session_environment);
let insert_idx = context.source_ws_idx.min(self.state.workspaces.len());
if let Some(active) = self.state.active {
if active >= insert_idx {
Expand Down Expand Up @@ -2983,6 +2985,7 @@ mod tests {
#[test]
fn api_pane_move_to_new_workspace_closes_empty_source_workspace() {
let mut app = app_with_linked_worktree();
app.update_session_environment(vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))]);
let source = app.state.workspaces[0].tabs[0].root_pane;
let source_terminal = app.state.workspaces[0].tabs[0]
.terminal_id(source)
Expand Down Expand Up @@ -3035,6 +3038,10 @@ mod tests {
assert_ne!(move_result.pane.pane_id, source_public);
assert_eq!(move_result.pane.terminal_id, source_terminal.to_string());
assert_eq!(app.state.workspaces.len(), 1);
assert_eq!(
app.state.workspaces[0].session_environment,
vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))]
);
assert_eq!(
app.state.workspaces[0].tabs[0].terminal_id(source),
Some(&source_terminal)
Expand Down Expand Up @@ -3191,6 +3198,7 @@ mod tests {
#[test]
fn api_pane_move_recovery_restores_removed_source_workspace() {
let mut app = app_with_linked_worktree();
app.update_session_environment(vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))]);
let source = app.state.workspaces[0].tabs[0].root_pane;
let source_terminal = app.state.workspaces[0].tabs[0]
.terminal_id(source)
Expand All @@ -3216,6 +3224,10 @@ mod tests {

assert_eq!(app.state.workspaces.len(), 1);
assert_eq!(app.state.workspaces[0].id, previous_workspace_id);
assert_eq!(
app.state.workspaces[0].session_environment,
vec![("WAYLAND_DISPLAY".into(), Some("wayland-1".into()))]
);
assert_eq!(
app.state.workspaces[0].tabs[0].terminal_id(source),
Some(&source_terminal)
Expand Down
1 change: 1 addition & 0 deletions src/app/creation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ impl App {
self.render_notify.clone(),
self.render_dirty.clone(),
extra_env,
&self.session_environment,
)?;
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
self.state.terminals.insert(terminal.id.clone(), terminal);
Expand Down
8 changes: 3 additions & 5 deletions src/app/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ impl App {
let tab_id = self.public_tab_id(ws_idx, tab_idx)?;
let pane_id = self.public_pane_id(ws_idx, pane_id)?;
Some(
crate::pane::PaneLaunchEnv::from_extra(extra_env).with_identity(
workspace_id,
tab_id,
pane_id,
),
crate::pane::PaneLaunchEnv::from_extra(extra_env)
.with_session(&self.session_environment)
.with_identity(workspace_id, tab_id, pane_id),
)
}

Expand Down
83 changes: 82 additions & 1 deletion src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mod theme_sync;
mod window_title;
mod worktrees;

use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::future::pending;
use std::io::{self, Write};
use std::sync::Arc;
Expand Down Expand Up @@ -161,6 +161,8 @@ pub struct App {
/// even when an App-internal drain consumes the event before the forwarding drain.
pub(crate) local_input_source_switch: bool,
pub(crate) config_reloaded_from_disk: bool,
pub(crate) session_environment: crate::pane::SessionEnvironment,
session_update_environment: Vec<String>,
prefix_input_source: Box<dyn crate::platform::PrefixInputSource>,
}

Expand Down Expand Up @@ -233,6 +235,16 @@ fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> boo
auto_updates_enabled(no_session) && check_enabled
}

fn filtered_session_update_environment(config: &crate::config::Config) -> Vec<String> {
config
.session
.update_environment
.iter()
.filter(|name| crate::config::session_environment_name_allowed(name))
.cloned()
.collect()
}

fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry {
if no_session {
return std::collections::HashMap::new();
Expand Down Expand Up @@ -797,6 +809,8 @@ impl App {
local_terminal_notifications: true,
local_input_source_switch: true,
config_reloaded_from_disk: false,
session_environment: Vec::new(),
session_update_environment: filtered_session_update_environment(config),
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);
Expand Down Expand Up @@ -1378,6 +1392,30 @@ impl App {
self.apply_config_from_disk(true)
}

pub(crate) fn update_session_environment(&mut self, update: Vec<(String, Option<String>)>) {
let allowed = |name: &str| {
self.session_update_environment
.iter()
.any(|candidate| candidate == name)
&& crate::config::session_environment_name_allowed(name)
};
self.session_environment = update
.into_iter()
.filter(|(name, value)| {
allowed(name) && value.as_ref().is_none_or(|v| !v.contains('\0'))
})
.collect::<BTreeMap<_, _>>()
.into_iter()
.collect();
self.sync_session_environment_to_workspaces();
}

fn sync_session_environment_to_workspaces(&mut self) {
for workspace in &mut self.state.workspaces {
workspace.set_session_environment(&self.session_environment);
}
}

pub(crate) fn take_config_reloaded_from_disk(&mut self) -> bool {
let reloaded = self.config_reloaded_from_disk;
self.config_reloaded_from_disk = false;
Expand Down Expand Up @@ -1441,6 +1479,14 @@ impl App {
}
}

if !invalid_section("session") {
self.session_update_environment = filtered_session_update_environment(config);
let allowed = &self.session_update_environment;
self.session_environment
.retain(|(name, _)| allowed.iter().any(|candidate| candidate == name));
self.sync_session_environment_to_workspaces();
}

if !invalid_section("ui") {
// Validate sidebar bounds before they reach any `u16::clamp` call.
// On `min > max`, treat the entire `[ui]` section as invalid: keep
Expand Down Expand Up @@ -2090,6 +2136,41 @@ mod tests {
)
}

#[test]
fn session_environment_update_filters_and_replaces_the_overlay() {
let mut app = test_app();
app.state.workspaces = vec![Workspace::test_new("test")];
app.session_update_environment = vec![
"DISPLAY".into(),
"SSH_AUTH_SOCK".into(),
"TERM".into(),
"HERDR_SOCKET_PATH".into(),
];

app.update_session_environment(vec![
("DISPLAY".into(), Some("client-display".into())),
("DISPLAY".into(), Some("latest-display".into())),
("SSH_AUTH_SOCK".into(), None),
("TERM".into(), Some("unsafe-term".into())),
("HERDR_SOCKET_PATH".into(), Some("unsafe-socket".into())),
("UNLISTED".into(), Some("ignored".into())),
]);

let expected = vec![
("DISPLAY".into(), Some("latest-display".into())),
("SSH_AUTH_SOCK".into(), None),
];
assert_eq!(app.session_environment, expected);
assert_eq!(app.state.workspaces[0].session_environment, expected);

app.update_session_environment(vec![("DISPLAY".into(), None)]);
assert_eq!(app.session_environment, vec![("DISPLAY".into(), None)]);
assert_eq!(
app.state.workspaces[0].session_environment,
app.session_environment
);
}

fn unique_temp_path(name: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down
4 changes: 3 additions & 1 deletion src/app/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ impl App {
let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let pane_id = PaneId::alloc();
let terminal_id = TerminalId::alloc();
let launch_env = PaneLaunchEnv::from_extra(extra_env).without_pane_identity();
let launch_env = PaneLaunchEnv::from_extra(extra_env)
.with_session(&self.session_environment)
.without_pane_identity();
let terminal_area = if self.state.view.terminal_area.width >= 4
&& self.state.view.terminal_area.height >= 4
{
Expand Down
78 changes: 71 additions & 7 deletions src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,43 @@ fn is_remote_client_process() -> bool {
std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok()
}

fn environment_update_entry(
name: String,
value: Option<std::ffi::OsString>,
) -> Option<(String, Option<String>)> {
match value {
Some(value) => value.into_string().ok().map(|value| (name, Some(value))),
None => Some((name, None)),
}
}

fn launch_mode_updates_environment(launch_mode: ClientLaunchMode) -> bool {
matches!(
launch_mode,
ClientLaunchMode::App | ClientLaunchMode::AppDirectGraphics
)
}

fn requested_environment_update() -> Option<Vec<(String, Option<String>)>> {
if is_remote_client_process() {
return None;
}
let config = crate::config::Config::load().config;
let mut update = config
.session
.update_environment
.into_iter()
.filter(|name| crate::config::session_environment_name_allowed(name))
.filter_map(|name| {
let value = std::env::var_os(&name);
environment_update_entry(name, value)
})
.collect::<Vec<_>>();
update.sort_by(|left, right| left.0.cmp(&right.0));
update.dedup_by(|left, right| left.0 == right.0);
Some(update)
}

/// Time to wait for the server's Welcome reply during the handshake.
///
/// A local client talks to an already-connected server, so 5s is plenty. The
Expand Down Expand Up @@ -836,6 +873,12 @@ fn do_handshake(
.map_err(ClientError::ConnectionFailed)?;

// Send Hello.
let launch_mode = client_launch_mode(
direct_attach_requested,
exact_cell_size,
cell_width_px,
cell_height_px,
);
let hello = ClientMessage::Hello {
version: PROTOCOL_VERSION,
cols,
Expand All @@ -844,12 +887,10 @@ fn do_handshake(
cell_height_px,
requested_encoding,
keybindings: requested_keybindings(),
launch_mode: client_launch_mode(
direct_attach_requested,
exact_cell_size,
cell_width_px,
cell_height_px,
),
launch_mode,
environment_update: launch_mode_updates_environment(launch_mode)
.then(requested_environment_update)
.flatten(),
};
protocol::write_message(stream, &hello)
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
Expand Down Expand Up @@ -2659,6 +2700,13 @@ mod tests {
client_launch_mode(true, false, 8, 16),
ClientLaunchMode::TerminalAttach
);
assert!(!launch_mode_updates_environment(
ClientLaunchMode::TerminalAttach
));
assert!(launch_mode_updates_environment(ClientLaunchMode::App));
assert!(launch_mode_updates_environment(
ClientLaunchMode::AppDirectGraphics
));
}

#[test]
Expand Down Expand Up @@ -2741,12 +2789,28 @@ mod tests {
}
}

#[cfg(unix)]
#[test]
fn non_utf8_environment_values_are_not_reported_as_missing() {
use std::os::unix::ffi::OsStringExt;

assert_eq!(
environment_update_entry("DISPLAY".into(), Some(OsString::from_vec(vec![0xff]))),
None
);
assert_eq!(
environment_update_entry("DISPLAY".into(), None),
Some(("DISPLAY".into(), None))
);
}

#[test]
fn remote_client_uses_extended_handshake_timeout() {
fn remote_client_uses_extended_handshake_timeout_without_forwarding_environment() {
let _guard = env_lock().lock().unwrap();
let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local");

assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT);
assert!(requested_environment_update().is_none());
}

#[test]
Expand Down
Loading
Loading