Skip to content

Commit 822454e

Browse files
authored
Merge pull request #206 from pchalasani/feat/amux-project-filters
Add repository and directory filters to Amux
2 parents 52b38d6 + 107697a commit 822454e

4 files changed

Lines changed: 249 additions & 1 deletion

File tree

‎claude_code_tools/amux/cli.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import sys
2121

2222
from . import cache, render, scan
23+
from .filters import directory_argument, select_agents
2324
from .model import Agent
2425

2526
_FZF_HEADER = (
@@ -46,6 +47,9 @@ def _agents_for_display(max_age: float) -> tuple[list[Agent], bool]:
4647

4748
def _display(agents: list[Agent], args: argparse.Namespace) -> list[Agent]:
4849
"""Refresh ages even for cached rows, then apply the requested view."""
50+
agents = select_agents(
51+
agents, getattr(args, "repo", None), getattr(args, "directory", None),
52+
)
4953
for agent in agents:
5054
agent.classify_inactivity(args.dormant_hours)
5155
if args.dormant:
@@ -112,6 +116,14 @@ def cmd_pick(args: argparse.Namespace) -> int:
112116
f" --dormant-hours {args.dormant_hours} --sort {args.sort}"
113117
+ (" --dormant" if args.dormant else "")
114118
)
119+
# Keep user values out of fzf's action/placeholder parser as well as the shell.
120+
reload_env = dict(os.environ)
121+
if args.repo is not None:
122+
reload_env["AMUX_RELOAD_REPO"] = args.repo
123+
view_flags += ' --repo="$AMUX_RELOAD_REPO"'
124+
if args.directory is not None:
125+
reload_env["AMUX_RELOAD_DIR"] = args.directory
126+
view_flags += ' --dir="$AMUX_RELOAD_DIR"'
115127
binds = [
116128
f"ctrl-r:reload({self_cmd} rows --refresh{view_flags})",
117129
# Opening on cached rows is what makes this instant; refresh the
@@ -153,6 +165,7 @@ def cmd_pick(args: argparse.Namespace) -> int:
153165
input=render.picker_lines(agents),
154166
stdout=subprocess.PIPE,
155167
text=True,
168+
env=reload_env,
156169
)
157170
if proc.returncode != 0 or not proc.stdout.strip():
158171
return 0
@@ -215,6 +228,14 @@ def build_parser() -> argparse.ArgumentParser:
215228
help="seconds a cached scan stays usable (default: 30)",
216229
)
217230
for command in (pick, lst, rows):
231+
command.add_argument(
232+
"--repo", "--project", dest="repo", metavar="NAME",
233+
help="exact repository name, including linked Git worktrees",
234+
)
235+
command.add_argument(
236+
"--dir", dest="directory", type=directory_argument, metavar="PATH",
237+
help="only agents in this directory or its subdirectories",
238+
)
218239
command.add_argument(
219240
"--dormant", action="store_true",
220241
help="show waiting agents with old known submitted input only",
@@ -238,7 +259,20 @@ def main(argv: list[str] | None = None) -> int:
238259
# only ["pick"] -- reparsing dropped global options, so `amux --max-age 0`
239260
# silently used the 30s default.
240261
known = {"pick", "list", "scan", "rows"}
241-
if not any(tok in known | {"-h", "--help"} for tok in raw):
262+
value_options = {
263+
"--repo", "--project", "--dir", "--max-age", "--dormant-hours", "--sort",
264+
}
265+
tokens = iter(raw)
266+
explicit_command = False
267+
for token in tokens:
268+
if token in value_options:
269+
next(tokens, None)
270+
elif token in known | {"-h", "--help"}:
271+
explicit_command = True
272+
break
273+
elif not token.startswith("-"):
274+
break
275+
if not explicit_command:
242276
raw.insert(0, "pick")
243277
args = parser.parse_args(raw)
244278
if not scan.tmux_available():

‎claude_code_tools/amux/filters.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Repository and directory selection for Amux display views."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
from pathlib import Path
7+
8+
from .model import Agent
9+
10+
11+
def directory_argument(value: str) -> str:
12+
"""Resolve an existing directory supplied on the command line."""
13+
try:
14+
path = Path(value).expanduser().resolve()
15+
if path.is_dir():
16+
return str(path)
17+
except (OSError, RuntimeError) as exc:
18+
raise argparse.ArgumentTypeError(str(exc)) from exc
19+
raise argparse.ArgumentTypeError(f"not a directory: {value!r}")
20+
21+
22+
def repository_name(cwd: str) -> str:
23+
"""Find the shared repository's directory name, including linked worktrees."""
24+
if not cwd:
25+
return ""
26+
try:
27+
path = Path(cwd).resolve()
28+
for root in (path, *path.parents):
29+
marker = root / ".git"
30+
if marker.is_dir():
31+
return root.name
32+
if marker.is_file():
33+
pointer = marker.read_text().strip()
34+
if not pointer.startswith("gitdir: "):
35+
return ""
36+
git_dir = (root / pointer.removeprefix("gitdir: ")).resolve()
37+
common_file = git_dir / "commondir"
38+
if not common_file.is_file():
39+
return root.name
40+
common = (git_dir / common_file.read_text().strip()).resolve()
41+
return common.parent.name if common.name == ".git" else common.name
42+
except (OSError, RuntimeError, UnicodeError):
43+
return ""
44+
return ""
45+
46+
47+
def select_agents(
48+
agents: list[Agent], repo: str | None, directory: str | None,
49+
) -> list[Agent]:
50+
"""Apply exact repository-name and directory-subtree filters together."""
51+
selected = []
52+
names: dict[str, str] = {}
53+
for agent in agents:
54+
if repo is not None and agent.repo != repo:
55+
if agent.cwd not in names:
56+
names[agent.cwd] = repository_name(agent.cwd)
57+
if names[agent.cwd] != repo:
58+
continue
59+
if directory:
60+
if not agent.cwd:
61+
continue
62+
try:
63+
Path(agent.cwd).resolve().relative_to(directory)
64+
except (ValueError, OSError, RuntimeError):
65+
continue
66+
selected.append(agent)
67+
return selected

‎docs-site/src/content/docs/tools/amux.mdx‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,28 @@ In the picker, rows are sorted by urgency, the right-hand pane shows a live
2626
preview of whatever is highlighted, `enter` jumps to that pane, and `ctrl-r`
2727
refreshes.
2828

29+
## Filter by repository or directory
30+
31+
```bash
32+
amux list --repo proposalwriter
33+
amux list --repo proposalwriter --dormant --sort oldest
34+
amux --repo proposalwriter # interactive picker
35+
amux list --dir ~/Git/proposalwriter # this directory and below
36+
amux --dir . # picker for the current directory
37+
```
38+
39+
`--repo NAME` (also `--project NAME`) matches the exact, case-sensitive repository
40+
name shown by Amux, or the main repository directory name for linked Git
41+
worktrees. Thus `--repo proposalwriter` also includes sessions in linked
42+
worktrees with different directory names. This matches local repository names,
43+
not GitHub `owner/repo` URLs.
44+
45+
`--dir PATH` matches a directory and its descendants, with `~`, relative paths,
46+
and symlinks resolved. It does not include sibling worktrees outside that
47+
subtree. Combine both flags to require both matches. Filters work with table,
48+
JSON, cached results, and the picker; automatic and Ctrl-r refreshes preserve
49+
them. The shared scan cache always retains agents from all projects.
50+
2951
## Agent states
3052

3153
Every pane running an agent gets one of four states, sorted most urgent first:

‎tests/test_amux_filters.py‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Project filters keep linked worktrees and picker refreshes consistent."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import subprocess
7+
from pathlib import Path
8+
9+
import pytest
10+
11+
from claude_code_tools.amux import cli
12+
from claude_code_tools.amux.filters import directory_argument, select_agents
13+
from claude_code_tools.amux.model import Agent
14+
15+
16+
def agent(cwd: Path, repo: str = "", pane: str = "test:1.1") -> Agent:
17+
"""Make a live-row fixture without touching agent processes."""
18+
return Agent(pane, "test", "claude", cwd=str(cwd), repo=repo)
19+
20+
21+
def test_repository_filter_includes_linked_worktree(tmp_path: Path) -> None:
22+
repo = tmp_path / "project"
23+
linked = tmp_path / "feature-tree"
24+
subprocess.run(["git", "init", "-q", str(repo)], check=True)
25+
subprocess.run([
26+
"git", "-C", str(repo), "-c", "user.name=Test",
27+
"-c", "user.email=test@example.invalid", "commit", "-qm", "initial",
28+
"--allow-empty",
29+
], check=True)
30+
subprocess.run([
31+
"git", "-C", str(repo), "worktree", "add", "-qb", "feature", str(linked),
32+
], check=True)
33+
(linked / "nested").mkdir()
34+
rows = [agent(repo, "project"), agent(linked / "nested", "feature-tree"),
35+
agent(tmp_path / "unrelated", "project-other")]
36+
assert select_agents(rows, "project", None) == rows[:2]
37+
assert select_agents(rows, "feature-tree", None) == [rows[1]]
38+
assert select_agents(rows, "Project", None) == []
39+
40+
41+
def test_directory_boundary_symlink_and_combination(tmp_path: Path) -> None:
42+
repo = tmp_path / "project"
43+
(repo / "nested").mkdir(parents=True)
44+
sibling = tmp_path / "project-other"
45+
sibling.mkdir()
46+
alias = tmp_path / "alias"
47+
alias.symlink_to(repo, target_is_directory=True)
48+
rows = [agent(repo, "project"), agent(repo / "nested", "project"),
49+
agent(sibling, "project"), agent(repo, "other"),
50+
Agent("test:1.9", "test", "claude", repo="project")]
51+
selected = select_agents(rows, "project", directory_argument(str(alias)))
52+
assert selected == rows[:2]
53+
assert select_agents(rows, None, str(repo)) == rows[:2] + [rows[3]]
54+
55+
56+
def test_directory_argument_rejects_file_or_missing(tmp_path: Path) -> None:
57+
regular = tmp_path / "file"
58+
regular.write_text("content")
59+
for path in [regular, tmp_path / "missing"]:
60+
with pytest.raises(argparse.ArgumentTypeError):
61+
directory_argument(str(path))
62+
63+
64+
def test_directory_argument_expands_relative_path(
65+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
66+
) -> None:
67+
monkeypatch.chdir(tmp_path)
68+
assert directory_argument(".") == str(tmp_path.resolve())
69+
70+
71+
@pytest.mark.parametrize("command", ["list", "pick", "rows"])
72+
def test_filters_combine_with_dormancy(command: str, tmp_path: Path) -> None:
73+
args = cli.build_parser().parse_args([
74+
command, "--project", "project", "--dir", str(tmp_path),
75+
"--dormant", "--sort", "oldest",
76+
])
77+
old = agent(tmp_path, "project")
78+
old.state, old.last_input_at = "idle", 1
79+
busy = agent(tmp_path, "project", "test:1.2")
80+
busy.state, busy.last_input_at = "busy", 1
81+
other = agent(tmp_path, "other", "test:1.3")
82+
other.state, other.last_input_at = "idle", 1
83+
assert cli._display([busy, other, old], args) == [old]
84+
85+
86+
@pytest.mark.parametrize("name", ["list", "pick", "scan", "rows"])
87+
def test_repo_named_like_subcommand_defaults_to_picker(
88+
name: str, monkeypatch: pytest.MonkeyPatch,
89+
) -> None:
90+
seen = []
91+
monkeypatch.setattr(cli.scan, "tmux_available", lambda: True)
92+
monkeypatch.setattr(cli, "cmd_pick", lambda args: seen.append(args) or 0)
93+
assert cli.main(["--repo", name]) == 0
94+
assert seen[0].repo == name
95+
96+
97+
def test_picker_refresh_preserves_literal_filter_values(
98+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
99+
) -> None:
100+
directory = tmp_path / "dir (x), {q} $HOME ' space"
101+
directory.mkdir()
102+
repo = "repo (x), {q} $(echo bad) '"
103+
row = agent(directory, repo)
104+
captured = {}
105+
monkeypatch.setattr(cli.shutil, "which", lambda _: "/usr/bin/fzf")
106+
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
107+
monkeypatch.setattr(cli.sys.stdout, "isatty", lambda: True)
108+
monkeypatch.setattr(cli, "_agents_for_display", lambda _: ([row], True))
109+
110+
def run(cmd: list[str], **kwargs: object) -> subprocess.CompletedProcess[str]:
111+
captured.update(command=cmd, **kwargs)
112+
return subprocess.CompletedProcess(cmd, 1, stdout="")
113+
114+
monkeypatch.setattr(cli.subprocess, "run", run)
115+
args = cli.build_parser().parse_args([
116+
"pick", "--repo", repo, "--dir", str(directory),
117+
])
118+
assert cli.cmd_pick(args) == 0
119+
binds = [x for x in captured["command"] if "reload" in x]
120+
assert len(binds) == 2
121+
assert all('--repo="$AMUX_RELOAD_REPO"' in x for x in binds)
122+
assert all('--dir="$AMUX_RELOAD_DIR"' in x for x in binds)
123+
assert all(repo not in x and str(directory) not in x for x in binds)
124+
assert captured["env"]["AMUX_RELOAD_REPO"] == repo
125+
assert captured["env"]["AMUX_RELOAD_DIR"] == str(directory)

0 commit comments

Comments
 (0)