fix(files): follow a coding agent into its git worktree

The Files tree rooted itself at the foreground process's cwd, read out of
the kernel (`proc_pidinfo` on macOS, `/proc/<pid>/cwd` on Linux). That is
the right answer for a shell and the wrong one for a coding agent: moving
into a git worktree does not `chdir`, so `claude` that entered
`.claude/worktrees/feature` still reports the directory it was launched in,
and the tree stayed rooted in the main checkout for the rest of the session.

tty7 already knows better. An agent's hooks report their own cwd, which
rides the OSC stream into `AgentSessionState::cwd`, and the git-status poll
has folded that over the process cwd since remote workspaces landed. Three
panels read the result — the pane's cwd row, the tab sidebar's path and the
source control panel — each with its own hand-rolled
`git_status_cwd().or_else(|| …cwd())`. The file tree was the one consumer
that never got the memo, which made the disagreement visible inside a single
panel: the cwd row said the worktree, the tree below it said the main repo.

Give the pattern a name — `TerminalView::effective_cwd`, plus an
`effective_host_cwd` for callers that hand the path to a `Host` — and route
all four through it. Behaviour is unchanged for the three that already
preferred the agent; the file tree now agrees with them. A pane with no
agent, or one whose turn just ended, falls back to the process cwd exactly
as before, so a stale worktree can never outlive the session that named it.
This commit is contained in:
l0ng-ai
2026-08-11 22:12:51 +08:00
parent c2950fc434
commit d7e10f31e5
6 changed files with 122 additions and 16 deletions
+8
View File
@@ -233,6 +233,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
unexpected password prompt on a host nobody had touched. It now confirms, and
when the endpoint is shared the dialog says how many others go with it.
- **The Files tree follows an agent into a git worktree** — the pane's cwd row,
the tab sidebar and the source control panel all track where a coding agent
is actually working, because an agent reports its own directory over the hook
stream. The file tree was the one panel still reading the foreground
process's cwd, and an agent that moves into a worktree never `chdir`s — so
`claude` entering `.claude/worktrees/feature` left the tree rooted in the
main checkout, disagreeing with the path printed directly above it.
- **`tty7 wait` no longer calls a busy shell `idle`** — a pane with nothing
reporting agent status was reported as `idle`, so `tty7 wait %3 --until idle`
returned success immediately, `matched: true`, about a pane that was midway
+105
View File
@@ -1398,6 +1398,31 @@ impl TerminalView {
self.git_status_cwd.as_deref()
}
/// The directory this pane's *work* is happening in — what every panel
/// that answers "where am I?" should show.
///
/// [`Self::cwd`] is the kernel's idea: the cwd of the foreground process.
/// That is right for a shell, and wrong for a coding agent, because an
/// agent moving into a git worktree does not `chdir` — the `claude`
/// process stays where it was launched while the session works somewhere
/// else entirely. The hook stream carries the agent's own cwd for exactly
/// this reason (`AgentSessionState::cwd`), and the git-status poll already
/// folds the two together into `git_status_cwd`; this reads that result
/// back out under a name that doesn't imply it's only about git.
///
/// `git_status_cwd` is only ever set for a pane whose paths belong to its
/// host, so the fallback here is what decides that: this one takes any
/// cwd, [`Self::effective_host_cwd`] takes only one the host can resolve.
pub fn effective_cwd(&self) -> Option<std::path::PathBuf> {
self.git_status_cwd.clone().or_else(|| self.cwd())
}
/// [`Self::effective_cwd`], restricted to paths the pane's host can act
/// on — for callers that will hand the result to a `Host` call.
pub fn effective_host_cwd(&self) -> Option<std::path::PathBuf> {
self.git_status_cwd.clone().or_else(|| self.host_cwd())
}
pub fn refresh_git_status_now(&mut self, cx: &mut Context<Self>) {
let cwd = self.git_status_cwd.clone();
if cwd.is_some() {
@@ -7718,6 +7743,86 @@ mod gpui_tests {
);
}
/// An agent that moves into a git worktree does not `chdir` — the process
/// stays put and only its hook stream says where the work went. Every
/// panel that answers "where am I?" reads `effective_cwd`, so this is the
/// one place that has to prefer the agent's answer over the kernel's.
#[gpui::test]
fn a_pane_follows_its_agent_into_a_worktree(cx: &mut TestAppContext) {
use crate::core::cli_agent::{AgentSessionState, AgentStatus};
use std::io::Write as _;
use std::path::PathBuf;
let launched_in = PathBuf::from("/repo");
let working_in = PathBuf::from("/repo/.claude/worktrees/wt");
let (window, mut daemon) = harness(cx);
DaemonMsg::Cwd(launched_in.clone())
.encode(&mut daemon)
.unwrap();
DaemonMsg::AgentStatus(Some(AgentSessionState {
status: AgentStatus::Working,
message: None,
session_id: Some("sid-wt".into()),
launch_argv: Some(vec!["claude".into()]),
rich: true,
cwd: Some(working_in.clone()),
activity: 0,
}))
.encode(&mut daemon)
.unwrap();
daemon.flush().unwrap();
for _ in 0..200 {
let seen = window
.update(cx, |view, _, _| {
view.cwd().is_some() && view.agent_session().is_some()
})
.unwrap();
if seen {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
assert_eq!(
view.cwd(),
Some(launched_in.clone()),
"the process really is still in the launch directory"
);
view.poll_foreground(window, cx);
assert_eq!(
view.effective_cwd(),
Some(working_in.clone()),
"the file tree, the cwd row and the SCM panel all root here"
);
assert_eq!(view.effective_host_cwd(), Some(working_in.clone()));
})
.unwrap();
// Turn over: the agent is gone, and with it any claim about where the
// work is. Falling back to a stale worktree would be worse than the
// bug this fixes.
DaemonMsg::AgentStatus(None).encode(&mut daemon).unwrap();
daemon.flush().unwrap();
for _ in 0..200 {
let gone = window
.update(cx, |view, _, _| view.agent_session().is_none())
.unwrap();
if gone {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, window, cx| {
view.poll_foreground(window, cx);
assert_eq!(view.effective_cwd(), Some(launched_in.clone()));
})
.unwrap();
}
/// A 1x1 red placement anchored at an absolute scrollback row, built the way
/// the decode worker hands one to the store.
fn placed_at(anchor_row: i64) -> crate::terminal::images::PlacedImage {
+6 -1
View File
@@ -732,10 +732,15 @@ impl Tty7App {
Some(tab) => tab.pane.terminals(),
None => Vec::new(),
};
// `effective_cwd`, not `cwd`: a pane running an agent that moved into
// a git worktree keeps its kernel cwd back at the launch directory, so
// the raw process cwd would root the tree in the wrong checkout — and
// in the wrong one *visibly*, since the cwd row directly above this
// tree already follows the agent.
let cwds: Vec<PathBuf> = leaves
.iter()
.filter(|leaf| leaf.read(cx).host_id() == id)
.filter_map(|leaf| leaf.read(cx).cwd())
.filter_map(|leaf| leaf.read(cx).effective_cwd())
.collect();
let mut roots: Vec<PathBuf> = Vec::new();
let mut resolved = true;
+1 -5
View File
@@ -477,11 +477,7 @@ impl Tty7App {
if let Some(leaf) = tab.detail_pane(window, cx) {
let view = leaf.read(cx);
pane_id = Some(view.pane_id);
if let Some(cwd) = view
.git_status_cwd()
.map(|p| p.to_path_buf())
.or_else(|| view.cwd())
{
if let Some(cwd) = view.effective_cwd() {
rows.push((t(L10nKey::PanelCwd), compact_path(&cwd)));
// Copy Path is right either way; Reveal only means anything
// when the path is on the machine the file manager can see.
+1 -4
View File
@@ -1041,10 +1041,7 @@ impl Tty7App {
) -> Option<(SharedHost, PathBuf)> {
let leaf = self.tabs.get(self.active)?.detail_pane(window, cx)?;
let view = leaf.read(cx);
let cwd = view
.git_status_cwd()
.map(Path::to_path_buf)
.or_else(|| view.host_cwd())?;
let cwd = view.effective_host_cwd()?;
Some((view.host(cx)?, cwd))
}
+1 -6
View File
@@ -425,12 +425,7 @@ impl Tty7App {
cwd_shown = tab
.pane
.focused_or_first(window, cx)
.and_then(|leaf| {
let view = leaf.read(cx);
view.git_status_cwd()
.map(|p| p.to_path_buf())
.or_else(|| view.cwd())
})
.and_then(|leaf| leaf.read(cx).effective_cwd())
.map(|cwd| {
let full = SharedString::from(
abbreviate_home(&cwd.display().to_string()).into_owned(),