mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user