diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-29 20:38:55 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-29 20:38:55 +0300 |
| commit | 9e302984ccb13a65e3e504fa7692b223f546ff84 (patch) | |
| tree | 52808a27bd82c05f55117ad129137fe3ef46a8cb | |
| parent | 77242943ba65a80cbc1180258dd64f06635233f9 (diff) | |
feat: expose foreground cwd in pane info
refs #345
| -rw-r--r-- | docs/next/CHANGELOG.md | 1 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/cli-reference.mdx | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/socket-api.mdx | 2 | ||||
| -rw-r--r-- | src/api/schema.rs | 6 | ||||
| -rw-r--r-- | src/app/agents.rs | 1 | ||||
| -rw-r--r-- | src/app/creation.rs | 3 | ||||
| -rw-r--r-- | src/pane.rs | 49 | ||||
| -rw-r--r-- | src/platform/linux.rs | 6 | ||||
| -rw-r--r-- | src/platform/macos.rs | 6 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 4 | ||||
| -rw-r--r-- | src/workspace/tab.rs | 11 | ||||
| -rw-r--r-- | tests/api_ping.rs | 154 |
12 files changed, 243 insertions, 2 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index f677bdb8..179f955b 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added - Added pane copy mode at `prefix+[` with keyboard navigation, visual selection, and clipboard yank support. (#231) +- Added `foreground_cwd` to pane and agent API/CLI responses so integrations can inspect the active foreground process directory without changing the existing pane/workspace `cwd` semantics. (#345) ### Fixed - WSL clipboard writes now prefer OSC 52 before WSLg clipboard tools, so mouse selection and double-click copy populate Windows clipboard history in Windows Terminal. (#333) diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index d79e34d0..ad837142 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -136,6 +136,8 @@ herdr pane report-agent <pane_id> \ `pane get`, `pane list`, `agent get`, and `agent list` include a read-only `agent_session` object when an official integration has reported a native session reference. If no native session reference is stored, the field is omitted. +Those commands include `foreground_cwd` when Herdr can resolve the cwd of the foreground process controlling the pane. The existing `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior. + Report display-only pane metadata without taking over semantic state: ```bash diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx index 382a3c62..8525c33b 100644 --- a/docs/next/website/src/content/docs/socket-api.mdx +++ b/docs/next/website/src/content/docs/socket-api.mdx @@ -178,6 +178,8 @@ Official integrations can also report a native session reference. `pane.get`, `p If no native session reference is stored, the field is omitted. +`pane.get`, `pane.list`, `agent.get`, and `agent.list` also expose `foreground_cwd` when Herdr can resolve the cwd of the process currently controlling the pane PTY. The existing `cwd` field remains the pane/workspace cwd used for labels, follow-cwd behavior, and restored session state. + Use `pane.report_metadata` when a user hook wants to customize presentation without taking over lifecycle state from a Herdr integration. ```json diff --git a/src/api/schema.rs b/src/api/schema.rs index cd82f99f..5d933af9 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -811,6 +811,8 @@ pub struct AgentInfo { pub focused: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_cwd: Option<String>, pub revision: u64, } @@ -824,6 +826,8 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub foreground_cwd: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub label: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, @@ -1407,6 +1411,7 @@ mod tests { tab_id: "w_1:1".into(), focused: true, cwd: Some("/worktrees/herdr/worktree-api".into()), + foreground_cwd: None, label: None, agent: None, title: None, @@ -1457,6 +1462,7 @@ mod tests { tab_id: "w_1:2".into(), focused: false, cwd: Some("/tmp/review".into()), + foreground_cwd: None, label: None, agent: None, title: None, diff --git a/src/app/agents.rs b/src/app/agents.rs index 55ee15df..6d463b28 100644 --- a/src/app/agents.rs +++ b/src/app/agents.rs @@ -423,6 +423,7 @@ impl App { pane_id: pane.pane_id, focused: pane.focused, cwd: pane.cwd, + foreground_cwd: pane.foreground_cwd, revision: pane.revision, }) } diff --git a/src/app/creation.rs b/src/app/creation.rs index d483eb1a..0f63d60d 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -288,6 +288,9 @@ impl App { cwd: ws.tabs[tab_idx] .cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes) .map(|cwd| cwd.display().to_string()), + foreground_cwd: ws.tabs[tab_idx] + .foreground_cwd_for_pane(pane_id, &self.terminal_runtimes) + .map(|cwd| cwd.display().to_string()), label: terminal.manual_label.clone(), agent: terminal.effective_agent_label().map(str::to_string), title: presentation.title, diff --git a/src/pane.rs b/src/pane.rs index 41bb439c..885fb11d 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -124,6 +124,29 @@ fn should_clear_agent_for_foreground_shell( previous_agent.is_some() && new_agent.is_none() && foreground_is_pane_shell } +fn usable_process_cwd(pid: u32) -> Option<std::path::PathBuf> { + crate::platform::process_cwd(pid).filter(|cwd| cwd.is_absolute() && cwd.is_dir()) +} + +fn foreground_member_cwd_different_from_shell( + shell_pid: u32, + shell_cwd: Option<&std::path::PathBuf>, +) -> Option<std::path::PathBuf> { + let job = crate::detect::foreground_job(shell_pid)?; + for process in job.processes { + if process.pid == shell_pid { + continue; + } + let Some(cwd) = usable_process_cwd(process.pid) else { + continue; + }; + if shell_cwd != Some(&cwd) { + return Some(cwd); + } + } + None +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ForegroundShellAgentAction { ObserveProbe, @@ -2111,6 +2134,32 @@ impl PaneRuntime { let pid = self.child_pid.load(Ordering::Relaxed); crate::platform::process_cwd(pid) } + + /// Get the current working directory of the process group controlling the pane PTY. + pub fn foreground_cwd(&self) -> Option<std::path::PathBuf> { + #[cfg(unix)] + { + let pid = self.child_pid.load(Ordering::Acquire); + let shell_cwd = usable_process_cwd(pid); + let foreground_pgid = self + .master_fd() + .and_then(crate::platform::foreground_process_group_id_for_tty_fd) + .or_else(|| crate::platform::foreground_process_group_id(pid)); + let leader_cwd = foreground_pgid.and_then(usable_process_cwd); + + if leader_cwd.as_ref() == shell_cwd.as_ref() { + foreground_member_cwd_different_from_shell(pid, shell_cwd.as_ref()).or(leader_cwd) + } else { + leader_cwd + .or_else(|| foreground_member_cwd_different_from_shell(pid, shell_cwd.as_ref())) + } + } + + #[cfg(not(unix))] + { + None + } + } } #[cfg(test)] diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 046e72c7..d1ae07f1 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -1,5 +1,6 @@ use std::{ io::Write, + os::fd::RawFd, path::PathBuf, process::{Command, Stdio}, }; @@ -86,6 +87,11 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> { (tpgid > 0).then_some(tpgid as u32) } +pub fn foreground_process_group_id_for_tty_fd(fd: RawFd) -> Option<u32> { + let pgid = unsafe { libc::tcgetpgrp(fd) }; + (pgid > 0).then_some(pgid as u32) +} + fn process_pgrp_and_comm(pid: u32) -> Option<(i32, String)> { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; let close = stat.rfind(')')?; diff --git a/src/platform/macos.rs b/src/platform/macos.rs index ac0e160b..e38b29eb 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -1,5 +1,6 @@ use std::ffi::OsStr; use std::io::Write; +use std::os::fd::RawFd; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -177,6 +178,11 @@ pub fn foreground_process_group_id(pid: u32) -> Option<u32> { } } +pub fn foreground_process_group_id_for_tty_fd(fd: RawFd) -> Option<u32> { + let pgid = unsafe { libc::tcgetpgrp(fd) }; + (pgid > 0).then_some(pgid as u32) +} + /// Get the effective process name from `argv[0]` via `sysctl(KERN_PROCARGS2)`. /// /// This is the macOS equivalent of reading `/proc/{pid}/cmdline` on Linux. diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 636972a1..b1a25ee9 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -373,6 +373,10 @@ impl TerminalRuntime { self.0.cwd() } + pub fn foreground_cwd(&self) -> Option<std::path::PathBuf> { + self.0.foreground_cwd() + } + pub(crate) fn current_size(&self) -> (u16, u16) { self.0.current_size() } diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs index d5ebe00f..100e67d0 100644 --- a/src/workspace/tab.rs +++ b/src/workspace/tab.rs @@ -405,4 +405,15 @@ impl Tab { .map(|terminal| terminal.cwd.clone()) }) } + + pub fn foreground_cwd_for_pane( + &self, + pane_id: PaneId, + terminal_runtimes: &TerminalRuntimeRegistry, + ) -> Option<PathBuf> { + let terminal_id = self.terminal_id(pane_id)?; + terminal_runtimes + .get(terminal_id) + .and_then(|rt| rt.foreground_cwd()) + } } diff --git a/tests/api_ping.rs b/tests/api_ping.rs index 064bac13..25e4c98e 100644 --- a/tests/api_ping.rs +++ b/tests/api_ping.rs @@ -72,8 +72,19 @@ fn wait_for_socket(path: &Path, timeout: Duration) { panic!("socket did not appear at {}", path.display()); } +fn wait_for_path(path: &Path, timeout: Duration) { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if path.exists() { + return; + } + thread::sleep(Duration::from_millis(25)); + } + panic!("path did not appear at {}", path.display()); +} + fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr { - spawn_herdr_with_path(config_home, runtime_dir, socket_path, None) + spawn_herdr_with_options(config_home, runtime_dir, socket_path, None, "/bin/sh") } fn spawn_herdr_with_path( @@ -82,6 +93,31 @@ fn spawn_herdr_with_path( socket_path: &Path, path_override: Option<&Path>, ) -> SpawnedHerdr { + spawn_herdr_with_options( + config_home, + runtime_dir, + socket_path, + path_override, + "/bin/sh", + ) +} + +fn spawn_herdr_with_shell( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, + shell: &str, +) -> SpawnedHerdr { + spawn_herdr_with_options(config_home, runtime_dir, socket_path, None, shell) +} + +fn spawn_herdr_with_options( + config_home: &Path, + runtime_dir: &Path, + socket_path: &Path, + path_override: Option<&Path>, + shell: &str, +) -> SpawnedHerdr { fs::create_dir_all(config_home.join("herdr")).unwrap(); fs::create_dir_all(runtime_dir).unwrap(); register_runtime_dir(runtime_dir); @@ -106,7 +142,7 @@ fn spawn_herdr_with_path( cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", socket_path); cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); - cmd.env("SHELL", "/bin/sh"); + cmd.env("SHELL", shell); cmd.env_remove("HERDR_ENV"); if let Some(path) = path_override { cmd.env("PATH", path); @@ -557,6 +593,120 @@ fn tab_methods_round_trip_over_socket() { cleanup_spawned_herdr(child, base); } +#[cfg(target_os = "linux")] +#[test] +fn pane_info_reports_foreground_cwd_without_changing_pane_cwd() { + let _lock = test_lock(); + let base = unique_test_dir(); + let foreground = base.join("foreground-process"); + let marker = base.join("foreground-ready"); + let pid_file = base.join("foreground.pid"); + fs::create_dir_all(&foreground).unwrap(); + let config_home = base.join("config"); + let runtime_dir = base.join("runtime"); + let socket_path = runtime_dir.join("herdr.sock"); + + let child = spawn_herdr_with_shell(&config_home, &runtime_dir, &socket_path, "/bin/bash"); + wait_for_socket(&socket_path, Duration::from_secs(5)); + + let created = send_request( + &socket_path, + &format!( + r#"{{"id":"fg_ws","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#, + base.display() + ), + ); + let pane_id = created["result"]["root_pane"]["pane_id"] + .as_str() + .unwrap() + .to_string(); + + let command = format!( + "/bin/sh -c 'cd {} && printf %s $$ > {} && touch {} && sleep 30'", + foreground.display(), + pid_file.display(), + marker.display() + ); + let send_text = send_request( + &socket_path, + &serde_json::json!({ + "id": "fg_send", + "method": "pane.send_text", + "params": { + "pane_id": pane_id, + "text": command, + }, + }) + .to_string(), + ); + assert_eq!(send_text["result"]["type"], "ok"); + let send_enter = send_request( + &socket_path, + &format!( + r#"{{"id":"fg_enter","method":"pane.send_keys","params":{{"pane_id":"{}","keys":["Enter"]}}}}"#, + pane_id + ), + ); + assert_eq!(send_enter["result"]["type"], "ok"); + wait_for_path(&marker, Duration::from_secs(5)); + + let foreground_pid: u32 = fs::read_to_string(&pid_file).unwrap().parse().unwrap(); + assert_eq!( + fs::read_link(format!("/proc/{foreground_pid}/cwd")).unwrap(), + foreground + ); + + let pane = send_request( + &socket_path, + &format!( + r#"{{"id":"fg_pane","method":"pane.get","params":{{"pane_id":"{}"}}}}"#, + pane_id + ), + ); + assert_eq!(pane["result"]["pane"]["cwd"], base.display().to_string()); + assert_eq!( + pane["result"]["pane"]["foreground_cwd"], + foreground.display().to_string() + ); + + let panes = send_request( + &socket_path, + r#"{"id":"fg_panes","method":"pane.list","params":{}}"#, + ); + assert_eq!( + panes["result"]["panes"][0]["cwd"], + base.display().to_string() + ); + assert_eq!( + panes["result"]["panes"][0]["foreground_cwd"], + foreground.display().to_string() + ); + + let reported = send_request( + &socket_path, + &format!( + r#"{{"id":"fg_report","method":"pane.report_agent","params":{{"pane_id":"{}","source":"test","agent":"probe","state":"working"}}}}"#, + pane_id + ), + ); + assert_eq!(reported["result"]["type"], "ok"); + + let agents = send_request( + &socket_path, + r#"{"id":"fg_agents","method":"agent.list","params":{}}"#, + ); + assert_eq!( + agents["result"]["agents"][0]["cwd"], + base.display().to_string() + ); + assert_eq!( + agents["result"]["agents"][0]["foreground_cwd"], + foreground.display().to_string() + ); + + cleanup_spawned_herdr(child, base); +} + #[cfg(not(target_os = "macos"))] #[test] fn agent_start_creates_named_terminal_over_socket() { |
