diff --git a/src/core/mod.rs b/src/core/mod.rs
index bd65e704..073b26a7 100644
--- a/src/core/mod.rs
+++ b/src/core/mod.rs
@@ -26,3 +26,4 @@ pub mod ssh_profile;
pub mod threads;
pub mod update;
pub mod window_state;
+pub mod worktree;
diff --git a/src/core/worktree.rs b/src/core/worktree.rs
new file mode 100644
index 00000000..d88796d7
--- /dev/null
+++ b/src/core/worktree.rs
@@ -0,0 +1,528 @@
+//! Git-worktree support for the tab context menu's "New Worktree Tab": derive
+//! the repo from a pane's cwd, propose an unused two-word name (editable in the
+//! sheet, see `ui::worktree_prompt`), and run `git worktree add -b` under the
+//! repository's own `.tty7/worktrees/` (kept out of `git status` by an
+//! auto-written self-ignoring `.tty7/.gitignore`) — so a coding agent gets an
+//! isolated checkout on its own branch, physically next to the code it forks.
+//! Blocking (spawns `git`); callers run it on the background executor, except
+//! [`is_inside_repo`], which is a pure filesystem probe cheap enough for
+//! menu-open time.
+
+use std::path::{Path, PathBuf};
+
+/// Word pools for generated branch names (`quiet-otter`). Short, lowercase,
+/// branch-safe; two pools of 24 give 576 combinations before the numeric
+/// fallback in [`defaults`] kicks in.
+const ADJECTIVES: [&str; 24] = [
+ "quiet", "amber", "bold", "calm", "cedar", "coral", "dusky", "early", "fable", "gold", "hazel",
+ "ivory", "jade", "keen", "lunar", "mossy", "noble", "ochre", "pale", "rapid", "sunny", "tidal",
+ "vivid", "wild",
+];
+const NOUNS: [&str; 24] = [
+ "otter", "heron", "lynx", "wren", "fox", "elk", "crane", "finch", "gecko", "ibis", "koala",
+ "llama", "marten", "newt", "osprey", "puffin", "quail", "raven", "seal", "tern", "urchin",
+ "vole", "walrus", "yak",
+];
+
+/// A freshly created worktree: where it lives and the branch checked out in it.
+#[derive(Debug)]
+pub struct NewWorktree {
+ pub path: PathBuf,
+ pub branch: String,
+}
+
+/// What to create, as confirmed (or edited) in the sheet: the checkout's
+/// directory name under the managed root, the new branch's name, and the
+/// commit-ish it starts from.
+#[derive(Debug, Clone)]
+pub struct WorktreeRequest {
+ pub name: String,
+ pub branch: String,
+ pub base: String,
+}
+
+/// Pre-filled values for the sheet: an unused two-word candidate (offered as
+/// both directory name and branch), the branch currently checked out (the
+/// natural start point; `"HEAD"` when detached), and the directory the new
+/// checkout would land in, for the live path preview.
+#[derive(Debug, Clone)]
+pub struct WorktreeDefaults {
+ pub name: String,
+ pub base: String,
+ pub dir: PathBuf,
+}
+
+/// Whether `cwd` sits inside a git repository — an upward scan for `.git`
+/// (a directory in a primary checkout, a file in a linked worktree or
+/// submodule). No subprocess: the tab context menu calls this while opening.
+pub fn is_inside_repo(cwd: &Path) -> bool {
+ cwd.ancestors().any(|d| d.join(".git").exists())
+}
+
+/// Run `git -C
`, returning trimmed stdout on success and trimmed
+/// stderr as the error otherwise.
+fn git(dir: &Path, args: &[&str]) -> Result {
+ let out = std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir)
+ .args(args)
+ .output()
+ .map_err(|e| format!("failed to run git: {e}"))?;
+ if out.status.success() {
+ Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
+ } else {
+ Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
+ }
+}
+
+/// Whether `name` already exists as a local branch in the repo at `repo_root`.
+/// A failed probe (`--verify --quiet` exits non-zero) means it's free.
+fn branch_exists(repo_root: &Path, name: &str) -> bool {
+ git(
+ repo_root,
+ &[
+ "rev-parse",
+ "--verify",
+ "--quiet",
+ &format!("refs/heads/{name}"),
+ ],
+ )
+ .is_ok()
+}
+
+/// A tiny xorshift over a time+pid seed — enough randomness to spread branch
+/// names without pulling in a `rand` dependency.
+fn seed() -> u64 {
+ let nanos = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos() as u64)
+ .unwrap_or(0x9E37_79B9_7F4A_7C15);
+ nanos ^ ((std::process::id() as u64) << 32) | 1
+}
+
+fn next(state: &mut u64) -> u64 {
+ *state ^= *state << 13;
+ *state ^= *state >> 7;
+ *state ^= *state << 17;
+ *state
+}
+
+/// One `adjective-noun` candidate from the pools.
+fn candidate(state: &mut u64) -> String {
+ let a = ADJECTIVES[(next(state) % ADJECTIVES.len() as u64) as usize];
+ let n = NOUNS[(next(state) % NOUNS.len() as u64) as usize];
+ format!("{a}-{n}")
+}
+
+/// A tty7-managed worktree a closing tab sat in, resolved for the
+/// close-time cleanup offer: where it is, its branch, the repository it
+/// belongs to, and whether it holds uncommitted changes.
+#[derive(Debug, Clone)]
+pub struct ManagedWorktree {
+ pub path: PathBuf,
+ pub branch: String,
+ pub main_root: PathBuf,
+ pub dirty: bool,
+}
+
+/// Resolve `cwd` to the tty7-managed worktree containing it, or `None` when it
+/// sits anywhere else. Only checkouts under the main repository's
+/// `.tty7/worktrees/` count — a user's own linked worktrees are never offered
+/// for removal. Blocking (spawns `git`).
+pub fn managed(cwd: &Path) -> Option {
+ // Canonicalize before the component test: git reports resolved physical
+ // paths (`/private/var/…` on macOS), while `cwd` may arrive through
+ // symlinks — a textual comparison would then never match. The `.tty7/
+ // worktrees` ancestor check is a cheap pure-filesystem pre-filter, so the
+ // common case (every ordinary tab close) never spawns git.
+ let cwd = std::fs::canonicalize(cwd).ok()?;
+ if !cwd
+ .ancestors()
+ .any(|a| a.ends_with(Path::new(".tty7").join("worktrees")))
+ {
+ return None;
+ }
+ let path = PathBuf::from(git(&cwd, &["rev-parse", "--show-toplevel"]).ok()?);
+ let main_root = git(
+ &path,
+ &["rev-parse", "--path-format=absolute", "--git-common-dir"],
+ )
+ .ok()
+ .map(PathBuf::from)?
+ .parent()?
+ .to_path_buf();
+ // The checkout must really sit in *this* repository's managed directory —
+ // both paths come from git, so they compare on equal (physical) footing.
+ if !path.starts_with(main_root.join(".tty7").join("worktrees")) {
+ return None;
+ }
+ let branch = git(&path, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
+ let dirty = !git(&path, &["status", "--porcelain"]).ok()?.is_empty();
+ Some(ManagedWorktree {
+ path,
+ branch,
+ main_root,
+ dirty,
+ })
+}
+
+/// Whether any of `cwds` still lives inside the worktree at `path` — removing
+/// the checkout then would pull the directory out from under a live shell (new
+/// tabs inherit the current cwd, so two tabs sharing one worktree is common).
+/// Both sides are canonicalized before the ancestor test — cwds may arrive
+/// through symlinks, and on Windows canonicalize adds a `\\?\` verbatim prefix
+/// that git-reported paths lack, so comparing raw would never match. A
+/// vanished path never counts as occupying.
+pub fn occupied(path: &Path, cwds: &[PathBuf]) -> bool {
+ let Ok(path) = std::fs::canonicalize(path) else {
+ return false;
+ };
+ cwds.iter()
+ .any(|c| std::fs::canonicalize(c).is_ok_and(|c| c.starts_with(&path)))
+}
+
+/// Remove a managed worktree (`git worktree remove`, `--force` to discard
+/// uncommitted changes), then best-effort delete its branch with `-d` — so a
+/// branch carrying unmerged commits survives the cleanup.
+pub fn remove(wt: &ManagedWorktree, force: bool) -> Result<(), String> {
+ let path = wt.path.to_str().ok_or("worktree path is not valid UTF-8")?;
+ let mut args = vec!["worktree", "remove"];
+ if force {
+ args.push("--force");
+ }
+ args.push(path);
+ git(&wt.main_root, &args)?;
+ let _ = git(&wt.main_root, &["branch", "-d", &wt.branch]);
+ Ok(())
+}
+
+/// Locate the repository containing `cwd` and the directory its managed
+/// worktrees live in: `(repo_root, /.tty7/worktrees)`. Anchored on
+/// the *main* repository even when `cwd` is itself inside a linked worktree
+/// (a worktree tab spawning another worktree), so checkouts never nest. The
+/// common git-dir is `/.git`, whose parent is the main root.
+fn repo_dir(cwd: &Path) -> Result<(PathBuf, PathBuf), String> {
+ let repo_root = git(cwd, &["rev-parse", "--show-toplevel"])
+ .map_err(|_| "not inside a git repository".to_string())?;
+ let repo_root = PathBuf::from(repo_root);
+ let main_root = git(
+ cwd,
+ &["rev-parse", "--path-format=absolute", "--git-common-dir"],
+ )
+ .ok()
+ .map(PathBuf::from)
+ .and_then(|d| d.parent().map(Path::to_path_buf))
+ .unwrap_or_else(|| repo_root.clone());
+ let dir = main_root.join(".tty7").join("worktrees");
+ Ok((repo_root, dir))
+}
+
+/// Compute the sheet's pre-filled values: a generated `adjective-noun` name
+/// (retried until both the branch and the directory are unused, with a
+/// numeric-suffix fallback so a saturated pool still terminates) and the
+/// currently checked-out branch as the start point.
+pub fn defaults(cwd: &Path) -> Result {
+ let (repo_root, dir) = repo_dir(cwd)?;
+
+ let mut state = seed();
+ let mut name = candidate(&mut state);
+ for attempt in 0..64 {
+ // Both the ref and the directory must be free — a stale directory from a
+ // hand-removed worktree would make `git worktree add` fail either way.
+ if !branch_exists(&repo_root, &name) && !dir.join(&name).exists() {
+ break;
+ }
+ name = if attempt < 32 {
+ candidate(&mut state)
+ } else {
+ format!("{}-{}", candidate(&mut state), next(&mut state) % 1000)
+ };
+ }
+
+ // Detached HEAD (or an unborn branch) has no abbrev-ref; start from HEAD.
+ let base = git(&repo_root, &["rev-parse", "--abbrev-ref", "HEAD"])
+ .unwrap_or_else(|_| "HEAD".to_string());
+ Ok(WorktreeDefaults { name, base, dir })
+}
+
+/// Create the requested worktree for the repository containing `cwd`, at
+/// `/.tty7/worktrees/`, on new branch `branch` starting from
+/// `base`. Branch and base validity is git's to judge; the directory name only
+/// has to stay a single path component so it can't escape the managed root.
+pub fn create(cwd: &Path, req: &WorktreeRequest) -> Result {
+ if req.name.is_empty() || req.name == "." || req.name == ".." || req.name.contains(['/', '\\'])
+ {
+ return Err(format!("invalid worktree name \"{}\"", req.name));
+ }
+ let (repo_root, dir) = repo_dir(cwd)?;
+ std::fs::create_dir_all(&dir).map_err(|e| format!("cannot create {}: {e}", dir.display()))?;
+ // A `*` gitignore inside `.tty7/` keeps the whole tree (checkouts included,
+ // the ignore file itself too) out of the repository's `git status`, without
+ // ever editing the repo's own .gitignore. Best-effort: a failed write only
+ // costs status noise, never the worktree.
+ let ignore = dir
+ .parent()
+ .expect(".tty7/worktrees has a parent")
+ .join(".gitignore");
+ if !ignore.exists() {
+ let _ = std::fs::write(&ignore, "*\n");
+ }
+
+ let path = dir.join(&req.name);
+ if path.exists() {
+ return Err(format!("{} already exists", path.display()));
+ }
+ git(
+ &repo_root,
+ &[
+ "worktree",
+ "add",
+ "-b",
+ &req.branch,
+ path.to_str().ok_or("worktree path is not valid UTF-8")?,
+ &req.base,
+ ],
+ )?;
+ Ok(NewWorktree {
+ path,
+ branch: req.branch.clone(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A fresh scratch dir under the system temp location, unique per test —
+ /// the same std-only pattern the config tests use (no tempfile dep).
+ fn scratch(name: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("tty7-wt-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+ }
+
+ /// Strip Windows' `\\?\` verbatim prefix so `std::fs::canonicalize` output
+ /// compares equal to the plain absolute paths git reports; a no-op on Unix.
+ fn plain(p: &Path) -> PathBuf {
+ let s = p.to_string_lossy();
+ PathBuf::from(s.strip_prefix(r"\\?\").unwrap_or(&s).to_string())
+ }
+
+ fn sh(dir: &Path, args: &[&str]) {
+ assert!(
+ std::process::Command::new(args[0])
+ .args(&args[1..])
+ .current_dir(dir)
+ .output()
+ .unwrap()
+ .status
+ .success(),
+ "command failed: {args:?}"
+ );
+ }
+
+ /// A throwaway repo with one commit, so `worktree add` has a HEAD to branch
+ /// from.
+ fn temp_repo(name: &str) -> PathBuf {
+ let dir = scratch(name);
+ sh(&dir, &["git", "init", "-q"]);
+ sh(&dir, &["git", "config", "user.email", "t@t"]);
+ sh(&dir, &["git", "config", "user.name", "t"]);
+ std::fs::write(dir.join("a.txt"), "a").unwrap();
+ sh(&dir, &["git", "add", "."]);
+ sh(&dir, &["git", "commit", "-q", "-m", "init"]);
+ dir
+ }
+
+ /// The simplest sensible request: directory and branch share `name`,
+ /// starting from HEAD — what the sheet submits when nothing is edited.
+ fn req(name: &str) -> WorktreeRequest {
+ WorktreeRequest {
+ name: name.into(),
+ branch: name.into(),
+ base: "HEAD".into(),
+ }
+ }
+
+ #[test]
+ fn candidate_is_two_pool_words() {
+ let mut state = seed();
+ let name = candidate(&mut state);
+ let (a, n) = name.split_once('-').unwrap();
+ assert!(ADJECTIVES.contains(&a));
+ assert!(NOUNS.contains(&n));
+ }
+
+ #[test]
+ fn is_inside_repo_scans_upward_for_dot_git() {
+ let repo = temp_repo("probe");
+ let sub = repo.join("deep/nested");
+ std::fs::create_dir_all(&sub).unwrap();
+ assert!(is_inside_repo(&sub));
+ let plain = scratch("probe-plain");
+ assert!(!is_inside_repo(&plain));
+ let _ = std::fs::remove_dir_all(&repo);
+ let _ = std::fs::remove_dir_all(&plain);
+ }
+
+ #[test]
+ fn defaults_proposes_fresh_name_current_branch_and_target_dir() {
+ let repo = temp_repo("dflt");
+ let d = defaults(&repo).unwrap();
+ assert!(!branch_exists(&repo, &d.name));
+ let head = git(&repo, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
+ assert_eq!(d.base, head);
+ // The target dir is the repo's own `.tty7/worktrees` (git reports the
+ // canonical root: /var → /private/var on macOS).
+ let canon = plain(&std::fs::canonicalize(&repo).unwrap());
+ assert_eq!(plain(&d.dir), canon.join(".tty7").join("worktrees"));
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn create_makes_worktree_on_new_branch_inside_the_repo() {
+ let repo = temp_repo("repo");
+ let wt = create(&repo, &req("quiet-otter")).unwrap();
+ assert!(wt.path.join("a.txt").exists());
+ assert!(branch_exists(&repo, &wt.branch));
+ // The worktree lands under `/.tty7/worktrees/`…
+ let canon = plain(&std::fs::canonicalize(&repo).unwrap());
+ assert_eq!(plain(&wt.path), canon.join(".tty7/worktrees/quiet-otter"));
+ // …on the new branch…
+ let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
+ assert_eq!(head, wt.branch);
+ // …and the auto-written `.tty7/.gitignore` keeps the main repo's
+ // status clean despite the checkout living inside it.
+ assert_eq!(
+ std::fs::read_to_string(canon.join(".tty7/.gitignore")).unwrap(),
+ "*\n"
+ );
+ assert_eq!(git(&repo, &["status", "--porcelain"]).unwrap(), "");
+ // A second request colliding on the directory is refused up front.
+ assert!(
+ create(&repo, &req("quiet-otter"))
+ .unwrap_err()
+ .contains("already exists")
+ );
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn create_honors_custom_branch_and_base() {
+ let repo = temp_repo("base");
+ // A `stable` branch one commit behind the default branch's HEAD.
+ sh(&repo, &["git", "branch", "stable"]);
+ std::fs::write(repo.join("b.txt"), "b").unwrap();
+ sh(&repo, &["git", "add", "."]);
+ sh(&repo, &["git", "commit", "-q", "-m", "second"]);
+ let wt = create(
+ &repo,
+ &WorktreeRequest {
+ name: "my-dir".into(),
+ branch: "feat/my-branch".into(),
+ base: "stable".into(),
+ },
+ )
+ .unwrap();
+ // Directory and branch names diverge as requested…
+ assert_eq!(wt.path.file_name().unwrap().to_str().unwrap(), "my-dir");
+ let head = git(&wt.path, &["rev-parse", "--abbrev-ref", "HEAD"]).unwrap();
+ assert_eq!(head, "feat/my-branch");
+ // …and the checkout starts from `stable` (no b.txt yet).
+ assert!(wt.path.join("a.txt").exists());
+ assert!(!wt.path.join("b.txt").exists());
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn create_rejects_escaping_names() {
+ let repo = temp_repo("names");
+ for bad in ["", ".", "..", "a/b", "a\\b"] {
+ let mut r = req("x");
+ r.name = bad.into();
+ assert!(
+ create(&repo, &r)
+ .unwrap_err()
+ .contains("invalid worktree name"),
+ "{bad:?} should be rejected"
+ );
+ }
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn create_from_a_linked_worktree_lands_in_the_main_repo() {
+ let repo = temp_repo("nest");
+ let first = create(&repo, &req("first-wt")).unwrap();
+ // Spawn the second worktree from *inside* the first: it must land in
+ // the main repo's `.tty7/worktrees`, not nest inside the first checkout.
+ let second = create(&first.path, &req("second-wt")).unwrap();
+ assert_eq!(second.path.parent().unwrap(), first.path.parent().unwrap());
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn managed_resolves_managed_checkouts_and_remove_cleans_up() {
+ let repo = temp_repo("mg");
+ let wt = create(&repo, &req("mg-wt")).unwrap();
+ // The repo root itself is never "managed"…
+ assert!(managed(&repo).is_none());
+ // …nor is a linked worktree the user made outside `.tty7/worktrees`.
+ let own = scratch("mg-own");
+ let _ = std::fs::remove_dir_all(&own);
+ sh(
+ &repo,
+ &[
+ "git",
+ "worktree",
+ "add",
+ "-b",
+ "own-branch",
+ own.to_str().unwrap(),
+ ],
+ );
+ assert!(managed(&own).is_none());
+ // Any path inside the managed checkout resolves to it, initially clean.
+ let sub = wt.path.join("sub");
+ std::fs::create_dir_all(&sub).unwrap();
+ let m = managed(&sub).unwrap();
+ assert_eq!(m.branch, wt.branch);
+ assert_eq!(m.path, wt.path);
+ assert!(!m.dirty);
+ // Uncommitted changes flip `dirty` and block a plain remove; --force
+ // discards them. The branch (no unique commits) is deleted with it.
+ std::fs::write(wt.path.join("b.txt"), "b").unwrap();
+ let m = managed(&wt.path).unwrap();
+ assert!(m.dirty);
+ assert!(remove(&m, false).is_err());
+ remove(&m, true).unwrap();
+ assert!(!wt.path.exists());
+ assert!(!branch_exists(&repo, &wt.branch));
+ let _ = std::fs::remove_dir_all(&repo);
+ let _ = std::fs::remove_dir_all(&own);
+ }
+
+ #[test]
+ fn occupied_detects_live_cwds_inside_the_worktree() {
+ let repo = temp_repo("occ");
+ let wt = create(&repo, &req("occ-wt")).unwrap();
+ let inside = wt.path.join("deep");
+ std::fs::create_dir_all(&inside).unwrap();
+ assert!(occupied(&wt.path, &[repo.clone(), inside]));
+ // Cwds elsewhere in the repo don't count…
+ assert!(!occupied(&wt.path, &[repo.clone()]));
+ // …and neither does a cwd that no longer exists.
+ assert!(!occupied(&wt.path, &[wt.path.join("gone")]));
+ let _ = std::fs::remove_dir_all(&repo);
+ }
+
+ #[test]
+ fn create_outside_a_repo_errors() {
+ let plain = scratch("plain");
+ let err = create(&plain, &req("x")).unwrap_err();
+ assert_eq!(err, "not inside a git repository");
+ assert_eq!(defaults(&plain).unwrap_err(), "not inside a git repository");
+ let _ = std::fs::remove_dir_all(&plain);
+ }
+}
diff --git a/src/ui/app.rs b/src/ui/app.rs
index 9e686d93..4c2ab6b3 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -282,6 +282,9 @@ pub struct Tty7App {
pub(crate) closed: Vec,
/// `Some` while a tab label is being renamed inline; `None` otherwise.
pub(crate) renaming: Option,
+ /// `Some` while the "New Worktree Tab" sheet is open (see
+ /// `ui::worktree_prompt`); `None` otherwise.
+ pub(crate) worktree_prompt: Option,
/// When `Some`, the active tab renders only this one leaf full-window
/// (Cmd+Shift+Enter maximize). Cleared on any structural / navigation change.
maximized: Option>,
@@ -529,6 +532,7 @@ impl Tty7App {
palette_sub: None,
closed: Vec::new(),
renaming: None,
+ worktree_prompt: None,
maximized: None,
mod_hint_badges: false,
mod_hint_gen: 0,
@@ -1922,6 +1926,10 @@ impl Tty7App {
// A rename in progress stores a fixed tab index; removing a tab shifts
// indices and would let the pending edit commit onto the wrong tab. Drop it.
self.renaming = None;
+ // Capture the tab's cwd *before* its panes are killed (the daemon can't
+ // report it afterwards): if it sat in a tty7-managed worktree, the
+ // cleanup offer below needs it.
+ let worktree_cwd = self.tab_cwd(index, window, cx);
// Snapshot the tab (layout + each pane's current cwd + name) onto the
// recently-closed stack so Cmd+Shift+T can bring it back.
let snapshot = tab_to_session(&self.tabs[index], cx);
@@ -1950,6 +1958,187 @@ impl Tty7App {
self.focus_active(window, cx);
self.save_session(cx);
cx.notify();
+ // The tab is gone; if it lived in a tty7-managed worktree, offer to
+ // clean the checkout up rather than letting them pile up silently.
+ self.offer_worktree_cleanup(worktree_cwd, cx);
+ }
+
+ /// After closing a tab that sat in a tty7-managed worktree (see
+ /// [`crate::core::worktree::managed`]), offer to remove the checkout: a
+ /// clean worktree gets a plain keep/remove prompt; one with uncommitted
+ /// changes defaults to keeping and makes discarding explicit. Removal also
+ /// deletes the branch when it carries no unmerged commits (`branch -d`).
+ /// No offer while any surviving pane still has its cwd inside the checkout
+ /// (new tabs inherit the current cwd, so shared worktrees are common) —
+ /// removal would yank the directory out from under a live shell.
+ /// Detection, the dirty probe, and removal all run off the UI thread.
+ fn offer_worktree_cleanup(&mut self, cwd: Option, cx: &mut Context) {
+ let Some(cwd) = cwd else { return };
+ // Every leaf of every surviving tab, not just focused panes — a shell
+ // tucked away in a split occupies the worktree all the same.
+ let open_cwds: Vec = self
+ .tabs
+ .iter()
+ .flat_map(|tab| tab.pane.leaves())
+ .filter_map(|leaf| leaf.read(cx).cwd())
+ .collect();
+ cx.spawn(async move |this, cx| {
+ let Some(wt) = cx
+ .background_spawn(async move {
+ crate::core::worktree::managed(&cwd)
+ .filter(|wt| !crate::core::worktree::occupied(&wt.path, &open_cwds))
+ })
+ .await
+ else {
+ return;
+ };
+ let detail = if wt.dirty {
+ format!(
+ "The closed tab's worktree at {} has uncommitted changes.",
+ wt.path.display()
+ )
+ } else {
+ format!(
+ "The closed tab's worktree at {} is clean.",
+ wt.path.display()
+ )
+ };
+ let title = format!("Remove worktree \"{}\"?", wt.branch);
+ let level = if wt.dirty {
+ PromptLevel::Warning
+ } else {
+ PromptLevel::Info
+ };
+ let remove_label = if wt.dirty {
+ "Discard Changes & Remove"
+ } else {
+ "Remove Worktree"
+ };
+ let Ok(answer) = this.update_in(cx, |_, window, cx| {
+ window.prompt(level, &title, Some(&detail), &["Keep", remove_label], cx)
+ }) else {
+ return;
+ };
+ if !matches!(answer.await, Ok(1)) {
+ return;
+ }
+ let force = wt.dirty;
+ let branch = wt.branch.clone();
+ let result = cx
+ .background_spawn(async move { crate::core::worktree::remove(&wt, force) })
+ .await;
+ let _ = this.update_in(cx, |_, window, cx| match result {
+ Ok(()) => window.push_notification(format!("Removed worktree \"{branch}\""), cx),
+ Err(e) => window.push_notification(format!("Worktree removal failed: {e}"), cx),
+ });
+ })
+ .detach();
+ }
+
+ /// Close every tab except `index` ("Close Other Tabs"). Iterates from the
+ /// end so removals never shift an index still to visit. Tabs holding a live
+ /// warn-on-close SSH session are skipped outright — the per-tab confirm
+ /// sheet is keyed by index, which a bulk close would immediately
+ /// invalidate — so they simply survive the sweep.
+ pub(crate) fn close_other_tabs(
+ &mut self,
+ index: usize,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ if index >= self.tabs.len() {
+ return;
+ }
+ for i in (0..self.tabs.len()).rev() {
+ if i == index || self.tab_has_warn_ssh(i, cx) {
+ continue;
+ }
+ self.close_tab(i, window, cx);
+ }
+ }
+
+ /// Close every tab after `index` ("Close Tabs to the Right" / "Close Tabs
+ /// Below" in the sidebar). Same end-first iteration and warn-SSH skip as
+ /// [`close_other_tabs`](Self::close_other_tabs).
+ pub(crate) fn close_tabs_right_of(
+ &mut self,
+ index: usize,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ for i in ((index + 1)..self.tabs.len()).rev() {
+ if self.tab_has_warn_ssh(i, cx) {
+ continue;
+ }
+ self.close_tab(i, window, cx);
+ }
+ }
+
+ /// The cwd of the tab's label-driving terminal (focused leaf, else first) —
+ /// what the tab context menu's "Copy Working Directory" copies and "New
+ /// Worktree Tab" derives the repo from.
+ pub(crate) fn tab_cwd(
+ &self,
+ index: usize,
+ window: &Window,
+ cx: &App,
+ ) -> Option {
+ self.tabs
+ .get(index)?
+ .pane
+ .focused_or_first(window, cx)
+ .and_then(|leaf| leaf.read(cx).cwd())
+ }
+
+ /// "New Worktree Tab": probe the repository containing the tab's cwd for
+ /// defaults (a fresh generated name, the current branch as start point) on
+ /// the background executor, then open the confirmation sheet
+ /// (`ui::worktree_prompt`) where name/branch/base can be edited before
+ /// anything is created. Failure to probe lands as a notification.
+ pub(crate) fn new_worktree_tab(
+ &mut self,
+ index: usize,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let Some(cwd) = self.tab_cwd(index, window, cx) else {
+ window.push_notification("This tab has no working directory yet", cx);
+ return;
+ };
+ cx.spawn(async move |this, cx| {
+ let probe_cwd = cwd.clone();
+ let result = cx
+ .background_spawn(async move { crate::core::worktree::defaults(&probe_cwd) })
+ .await;
+ let _ = this.update_in(cx, |this, window, cx| match result {
+ Ok(defaults) => this.open_worktree_prompt(cwd, defaults, window, cx),
+ Err(e) => window.push_notification(format!("New worktree failed: {e}"), cx),
+ });
+ })
+ .detach();
+ }
+
+ /// Open the tab for a just-created worktree: a default-shell terminal in
+ /// the worktree directory, with the tab pre-named after its branch so a
+ /// strip of parallel worktrees stays tellable-apart. Mirrors
+ /// `new_tab_with_shell`, minus the cwd inheritance (the cwd *is* the point).
+ pub(crate) fn open_worktree_tab(
+ &mut self,
+ wt: crate::core::worktree::NewWorktree,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let view = new_terminal(self.font_size, Some(wt.path), None, None, window, cx);
+ self.remember_active_pane(window, cx);
+ self.maximized = None;
+ let insert_at = self.new_tab_insert_at(cx);
+ let mut tab = Tab::new(Pane::leaf(view));
+ tab.name = Some(wt.branch);
+ self.tabs.insert(insert_at, tab);
+ self.active = insert_at;
+ self.focus_active(window, cx);
+ self.save_session(cx);
+ cx.notify();
}
/// Reorder tabs: move the tab at `from` to position `to` (drag-and-drop).
@@ -3449,6 +3638,10 @@ impl Render for Tty7App {
.when_some(self.render_ssh_close_confirm_overlay(cx), |this, el| {
this.child(el)
})
+ // "New Worktree Tab" confirmation sheet (from the tab context menu).
+ .when_some(self.render_worktree_prompt_overlay(cx), |this, el| {
+ this.child(el)
+ })
// Working-tree diff overlay (clicked from a sidebar git line) —
// last child, so it paints over every pane-contextual element
// above. It covers only the body: the sidebar stays interactive.
@@ -3642,6 +3835,10 @@ impl Render for Tty7App {
.when_some(settings_overlay, |this, overlay| this.child(overlay))
// Command palette overlay, layered above everything when open.
.when_some(self.palette.clone(), |this, palette| this.child(palette))
+ // Toast layer for `window.push_notification` (worktree/SSH errors).
+ // gpui-component's Root only *stores* the list; the root view must
+ // render the layer — without this child every toast was invisible.
+ .children(gpui_component::Root::render_notification_layer(window, cx))
}
}
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
index 72856df4..ec8f20b0 100644
--- a/src/ui/mod.rs
+++ b/src/ui/mod.rs
@@ -23,3 +23,4 @@ pub mod ssh_prompt;
pub mod tab_sidebar;
pub mod tab_strip;
pub mod theme;
+pub mod worktree_prompt;
diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs
index 319d616c..f8ebb1ce 100644
--- a/src/ui/tab_sidebar.rs
+++ b/src/ui/tab_sidebar.rs
@@ -16,6 +16,7 @@ use gpui::{
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
+use gpui_component::menu::ContextMenuExt as _;
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use std::cell::Cell;
use std::rc::Rc;
@@ -323,7 +324,13 @@ impl Tty7App {
.into_any_element()
});
- list = list.child(row);
+ // Per-tab right-click menu, shared with the strip's chips;
+ // `below_wording` flips the trailing close to "Close Tabs Below"
+ // to match the vertical layout.
+ let menu_app = cx.entity().downgrade();
+ list = list.child(row.context_menu(move |menu, window, cx| {
+ Tty7App::tab_context_menu(menu, i, true, &menu_app, window, cx)
+ }));
}
// Top control bar: a right-aligned "+" new-tab button (the same shell
diff --git a/src/ui/tab_strip.rs b/src/ui/tab_strip.rs
index c8ff4a35..7f29370b 100644
--- a/src/ui/tab_strip.rs
+++ b/src/ui/tab_strip.rs
@@ -5,12 +5,12 @@
//! orchestration rather than chrome rendering.
use gpui::{
- App, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div, prelude::*,
- px,
+ App, Axis, Context, FontWeight, MouseButton, MouseDownEvent, SharedString, Window, div,
+ prelude::*, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
-use gpui_component::menu::{DropdownMenu as _, PopupMenuItem};
+use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex};
use crate::core::actions::{OpenSettings, TogglePalette};
@@ -377,6 +377,128 @@ impl Tty7App {
})
}
+ /// Build the per-tab right-click menu, shared by the strip's chips and the
+ /// sidebar's rows (which passes `below_wording` so the trailing close reads
+ /// "Close Tabs Below" in the vertical list). Live state — tab count, the
+ /// tab's cwd — is read at open time through the weak `app` handle, so the
+ /// render loop never pays a per-frame cwd syscall and the enablement can't
+ /// go stale between render and click.
+ pub(crate) fn tab_context_menu(
+ menu: PopupMenu,
+ index: usize,
+ below_wording: bool,
+ app: &gpui::WeakEntity,
+ window: &Window,
+ cx: &App,
+ ) -> PopupMenu {
+ let Some(entity) = app.upgrade() else {
+ return menu;
+ };
+ let this = entity.read(cx);
+ let tab_count = this.tabs.len();
+ let cwd = this.tab_cwd(index, window, cx);
+ let has_cwd = cwd.is_some();
+ let mut menu = menu.min_w(px(200.));
+
+ // Rename — the same inline edit a label double-click starts, given a
+ // discoverable entry point.
+ menu = menu.item(PopupMenuItem::new("Rename Tab").on_click({
+ let app = app.clone();
+ move |_, window, cx| {
+ let _ = app.update(cx, |this, cx| this.start_rename(index, window, cx));
+ }
+ }));
+
+ // Worktree: an isolated checkout of this tab's repo on a fresh branch,
+ // opened as a new tab — parallel-agent fuel. Only offered when the
+ // tab's cwd actually sits in a git repository (a filesystem-only
+ // probe, cheap enough at open time); outside one the entry would be
+ // pure noise.
+ let in_repo = cwd
+ .as_deref()
+ .is_some_and(crate::core::worktree::is_inside_repo);
+ if in_repo {
+ menu = menu
+ .separator()
+ .item(PopupMenuItem::new("New Worktree Tab").on_click({
+ let app = app.clone();
+ move |_, window, cx| {
+ let _ = app.update(cx, |this, cx| this.new_worktree_tab(index, window, cx));
+ }
+ }));
+ }
+
+ // Splits act on the right-clicked tab: activate it first (a no-op when
+ // it already is), then split its focused pane — one code path with the
+ // keyboard actions.
+ menu = menu
+ .separator()
+ .item(PopupMenuItem::new("Split Right").on_click({
+ let app = app.clone();
+ move |_, window, cx| {
+ let _ = app.update(cx, |this, cx| {
+ this.activate(index, window, cx);
+ this.split(Axis::Horizontal, window, cx);
+ });
+ }
+ }))
+ .item(PopupMenuItem::new("Split Down").on_click({
+ let app = app.clone();
+ move |_, window, cx| {
+ let _ = app.update(cx, |this, cx| {
+ this.activate(index, window, cx);
+ this.split(Axis::Vertical, window, cx);
+ });
+ }
+ }));
+
+ menu = menu.separator().item(
+ PopupMenuItem::new("Copy Working Directory")
+ .disabled(!has_cwd)
+ .on_click(move |_, _window, cx| {
+ if let Some(cwd) = cwd.as_ref() {
+ cx.write_to_clipboard(gpui::ClipboardItem::new_string(
+ cwd.display().to_string(),
+ ));
+ }
+ }),
+ );
+
+ menu.separator()
+ .item(PopupMenuItem::new("Close Tab").on_click({
+ let app = app.clone();
+ move |_, window, cx| {
+ let _ = app.update(cx, |this, cx| this.close_tab(index, window, cx));
+ }
+ }))
+ .item(
+ PopupMenuItem::new("Close Other Tabs")
+ .disabled(tab_count <= 1)
+ .on_click({
+ let app = app.clone();
+ move |_, window, cx| {
+ let _ =
+ app.update(cx, |this, cx| this.close_other_tabs(index, window, cx));
+ }
+ }),
+ )
+ .item(
+ PopupMenuItem::new(if below_wording {
+ "Close Tabs Below"
+ } else {
+ "Close Tabs to the Right"
+ })
+ .disabled(index + 1 >= tab_count)
+ .on_click({
+ let app = app.clone();
+ move |_, window, cx| {
+ let _ =
+ app.update(cx, |this, cx| this.close_tabs_right_of(index, window, cx));
+ }
+ }),
+ )
+ }
+
/// The horizontal tab strip rendered into the title bar. `show_chips` draws
/// the per-tab chip row; passing `false` (the vertical-sidebar mode, where
/// the sidebar owns the tab list) keeps only the "+" and "⋯" chrome so the
@@ -639,7 +761,12 @@ impl Tty7App {
.into_any_element()
});
- chips = chips.child(chip);
+ // Per-tab right-click menu (rename / worktree / split / copy cwd /
+ // close group) — the same builder the sidebar rows use.
+ let menu_app = cx.entity().downgrade();
+ chips = chips.child(chip.context_menu(move |menu, window, cx| {
+ Self::tab_context_menu(menu, i, false, &menu_app, window, cx)
+ }));
}
// "+" new-tab button — click opens the shell picker. The default shell
diff --git a/src/ui/worktree_prompt.rs b/src/ui/worktree_prompt.rs
new file mode 100644
index 00000000..f9f3f5b0
--- /dev/null
+++ b/src/ui/worktree_prompt.rs
@@ -0,0 +1,233 @@
+//! The "New Worktree Tab" sheet: confirms (or edits) the generated worktree
+//! name, the new branch, and the branch it starts from before anything touches
+//! git. Opened from the tab context menu (`tab_strip::tab_context_menu`); the
+//! defaults are probed off the UI thread in `Tty7App::new_worktree_tab`.
+
+use gpui::{AnyElement, Context, Entity, Subscription, Window, div, prelude::*, px};
+use gpui_component::button::{Button, ButtonVariants as _};
+use gpui_component::input::{Input, InputEvent, InputState};
+use gpui_component::{
+ ActiveTheme as _, Disableable as _, Sizable as _, WindowExt as _, h_flex, v_flex,
+};
+
+use crate::core::worktree::{WorktreeDefaults, WorktreeRequest};
+use crate::ui::app::Tty7App;
+
+/// State for the open sheet. Held on [`Tty7App`] so it survives re-renders and
+/// tab switches; there is at most one, app-wide.
+pub(crate) struct WorktreePrompt {
+ /// The directory the repo was derived from (the right-clicked tab's cwd) —
+ /// what the eventual `git worktree add` resolves the repository through.
+ cwd: std::path::PathBuf,
+ /// Where the checkout will land (`/`), for the live path
+ /// preview under the name field.
+ dir: std::path::PathBuf,
+ name: Entity,
+ branch: Entity,
+ base: Entity,
+ /// True while `git worktree add` runs, so a second Enter can't double-create.
+ busy: bool,
+ _subs: Vec,
+}
+
+impl Tty7App {
+ /// Open the sheet with probed defaults: the generated candidate fills both
+ /// the name and the branch (edit either independently), the current branch
+ /// fills the start point. Focus lands on the name field; Enter anywhere
+ /// submits, Esc cancels.
+ pub(crate) fn open_worktree_prompt(
+ &mut self,
+ cwd: std::path::PathBuf,
+ defaults: WorktreeDefaults,
+ window: &mut Window,
+ cx: &mut Context,
+ ) {
+ let name = cx.new(|cx| InputState::new(window, cx).default_value(defaults.name.clone()));
+ let branch = cx.new(|cx| InputState::new(window, cx).default_value(defaults.name));
+ let base = cx.new(|cx| InputState::new(window, cx).default_value(defaults.base));
+ name.update(cx, |state, cx| state.focus(window, cx));
+ let subs = [&name, &branch, &base]
+ .into_iter()
+ .map(|input| {
+ cx.subscribe_in(input, window, |this, _, ev: &InputEvent, window, cx| {
+ match ev {
+ InputEvent::PressEnter { .. } => this.submit_worktree_prompt(window, cx),
+ // Keep the path preview tracking the name field.
+ InputEvent::Change => cx.notify(),
+ _ => {}
+ }
+ })
+ })
+ .collect();
+ self.worktree_prompt = Some(WorktreePrompt {
+ cwd,
+ dir: defaults.dir,
+ name,
+ branch,
+ base,
+ busy: false,
+ _subs: subs,
+ });
+ cx.notify();
+ }
+
+ fn cancel_worktree_prompt(&mut self, window: &mut Window, cx: &mut Context) {
+ if self.worktree_prompt.take().is_some() {
+ self.focus_active(window, cx);
+ cx.notify();
+ }
+ }
+
+ /// Validate the fields and run the creation off the UI thread. Blanking
+ /// one of name/branch falls back to the other (one name is enough); a
+ /// blank start point means the repo's HEAD. On failure the sheet stays up
+ /// with the values intact, so a typo'd branch is a fix away.
+ fn submit_worktree_prompt(&mut self, window: &mut Window, cx: &mut Context) {
+ let Some(p) = self.worktree_prompt.as_ref() else {
+ return;
+ };
+ if p.busy {
+ return;
+ }
+ let name = p.name.read(cx).value().trim().to_string();
+ let branch = p.branch.read(cx).value().trim().to_string();
+ let base = p.base.read(cx).value().trim().to_string();
+ let (name, branch) = match (name.is_empty(), branch.is_empty()) {
+ (true, true) => {
+ window.push_notification("The worktree needs a name", cx);
+ return;
+ }
+ (true, false) => (branch.clone(), branch),
+ (false, true) => (name.clone(), name),
+ (false, false) => (name, branch),
+ };
+ let req = WorktreeRequest {
+ name,
+ branch,
+ base: if base.is_empty() {
+ "HEAD".to_string()
+ } else {
+ base
+ },
+ };
+ let p = self.worktree_prompt.as_mut().expect("checked above");
+ p.busy = true;
+ let cwd = p.cwd.clone();
+ cx.notify();
+ cx.spawn(async move |this, cx| {
+ let result = cx
+ .background_spawn(async move { crate::core::worktree::create(&cwd, &req) })
+ .await;
+ let _ = this.update_in(cx, |this, window, cx| match result {
+ Ok(wt) => {
+ this.worktree_prompt = None;
+ this.open_worktree_tab(wt, window, cx);
+ }
+ Err(e) => {
+ if let Some(p) = this.worktree_prompt.as_mut() {
+ p.busy = false;
+ }
+ window.push_notification(format!("New worktree failed: {e}"), cx);
+ cx.notify();
+ }
+ });
+ })
+ .detach();
+ }
+
+ /// The sheet itself, floated near the top of the terminal area like the
+ /// SSH auth sheet. `None` while no prompt is open.
+ pub(crate) fn render_worktree_prompt_overlay(
+ &self,
+ cx: &mut Context,
+ ) -> Option {
+ let p = self.worktree_prompt.as_ref()?;
+ let muted = cx.theme().muted_foreground;
+ let field = |label: &'static str, input: &Entity| {
+ v_flex()
+ .gap_1()
+ .child(div().text_xs().text_color(muted).child(label))
+ .child(Input::new(input).small())
+ };
+ // Live preview of where the checkout will land, following the name field.
+ let name_now = p.name.read(cx).value().trim().to_string();
+ let preview = p
+ .dir
+ .join(if name_now.is_empty() {
+ "…"
+ } else {
+ &name_now
+ })
+ .display()
+ .to_string();
+
+ let card = v_flex()
+ .occlude()
+ .w(px(420.))
+ .gap_3()
+ .p_4()
+ .bg(cx.theme().popover)
+ .border_1()
+ .border_color(cx.theme().border)
+ .rounded_lg()
+ .shadow_lg()
+ // Esc cancels from anywhere in the sheet (Enter submits via the
+ // inputs' PressEnter events).
+ .on_key_down(cx.listener(|this, ev: &gpui::KeyDownEvent, window, cx| {
+ if ev.keystroke.key == "escape" {
+ this.cancel_worktree_prompt(window, cx);
+ }
+ }))
+ .child(
+ div()
+ .text_sm()
+ .font_weight(gpui::FontWeight::SEMIBOLD)
+ .child("New Worktree Tab"),
+ )
+ .child(field("Worktree Name", &p.name))
+ .child(
+ div()
+ .text_xs()
+ .font_family("monospace")
+ .text_color(muted)
+ .child(preview),
+ )
+ .child(field("New Branch", &p.branch))
+ .child(field("Start From", &p.base))
+ .child(
+ h_flex()
+ .gap_2()
+ .child(
+ Button::new("worktree-create")
+ .label(if p.busy { "Creating…" } else { "Create" })
+ .small()
+ .primary()
+ .disabled(p.busy)
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.submit_worktree_prompt(window, cx)
+ })),
+ )
+ .child(
+ Button::new("worktree-cancel")
+ .label("Cancel")
+ .small()
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.cancel_worktree_prompt(window, cx)
+ })),
+ ),
+ );
+
+ Some(
+ div()
+ .absolute()
+ .inset_0()
+ .flex()
+ .flex_col()
+ .items_center()
+ .justify_start()
+ .pt(px(48.))
+ .child(card)
+ .into_any_element(),
+ )
+ }
+}