claude-sessions: folder browser, unique topic, and location column in menu

Claude-Session: https://claude.ai/code/session_01JXneLTuvb5Gxim3DTN2tQa
This commit is contained in:
Paul O'Reilly
2026-10-26 00:26:18 +13:00
parent 97567b26cc
commit c32ea467e4
4 changed files with 250 additions and 15 deletions

View File

@@ -4,6 +4,7 @@ Current work focus and active tasks. Used by `context-load` to orient agents at
## Recent
- `claude-sessions` (2026-10-26): menu "new" now offers a folder browser (zenity or terminal, with new-folder), a topic prompt (defaults to folder name, must be unique among live sessions), and a Location column in the list. Uncommitted; not yet tried live from the menu.
- `claude-sessions` (2026-10-13): new session defaults to cwd's CLAUDE.md project; new and resumed sessions attach (recover attaches only when one session is selected). Pushed (11ee50e). Not yet tried live end-to-end from the menu.
- Sonnet driver in `small-scripts/scripts/claude-profile` now `claude-sonnet-5-5`; other drivers (haiku, opus, fable) still on older IDs.
- Untracked, left alone: `insights-claude-md-profile-PLAN.md`, `scripts/__pycache__/` (consider gitignoring).

View File

@@ -6,6 +6,7 @@
## Scripts
- [claude-sessions](memory/script-claude-sessions.md) — tmux session manager: menu with folder browser, unique topic, location column
- [context-load](memory/script-context-load.md) — Walks cwd upward collecting CLAUDE.md, CONTEXT.md, MEMORY.md, BESTPRACTICES.md, trees, and git status for system prompt injection
- [start-claude](memory/script-start-claude.md) — Wrapper that runs context-load and launches claude with --append-system-prompt
- [install-hooks](memory/script-install-hooks.md) — Symlinks hooks into ~/.claude/hooks/ and prints settings.json config

View File

@@ -0,0 +1,22 @@
---
name: script-claude-sessions
description: claude-sessions — tmux session manager; interactive menu with folder picker, unique topic, and location column
metadata:
type: reference
---
# claude-sessions
Python script (`scripts/claude-sessions`, symlinked to `~/sbin/`). Manages Claude Code tmux sessions: list, recover, resume, spawn, gc. Run with no args for the interactive menu.
## Menu behaviour (2026-10-26)
- **Location column.** Live and resumable rows show the session folder (`~` for `$HOME`). Live rows take it from the registry (`project_dir`), falling back to tmux `#{session_path}`.
- **`n` New session.** Asks for a folder: Enter accepts the nearest `CLAUDE.md` folder, `b` opens the folder browser, or type a project name/path (same lookup as `claude-tmux`).
- **Folder browser.** `zenity --file-selection --directory` when `DISPLAY`/`WAYLAND_DISPLAY` is set and zenity exists (has Create Folder). Otherwise a terminal browser: number = open, `u` up, `n` new folder, `/text` filter, `>` `<` page, `h` hidden, `~`/`/path` jump, Enter/`s` select, `q` cancel.
- **Topic.** Becomes the tmux session name and the Remote Control name (passed as `--name` to `claude-tmux`). Defaults to the Title-Cased folder name. If that name is already a live tmux session, no default is offered and a unique topic is required. Topics containing `.` or `:` are rejected because tmux rewrites them to `_`.
## Gotchas
- "In use" means a live tmux session. Resumable registry entries do not block a topic.
- `claude-tmux` still auto-appends `#N` on a collision, but the menu rejects collisions first.

View File

@@ -3,7 +3,7 @@
claude-sessions — Session lifecycle manager for Claude Code tmux sessions.
Commands:
(no args) Interactive menu
(no args) Interactive menu (new session: folder browser + unique topic)
list Show live Claude tmux sessions and resumable registry entries
recover [--days N] Detect sessions active before a restart
spawn <project> Spawn a new session (wraps claude-tmux)
@@ -162,6 +162,190 @@ def title_case(s: str) -> str:
return " ".join(w.capitalize() for w in s.replace("-", " ").replace("_", " ").split())
def live_session_paths() -> dict[str, str]:
"""Map live tmux session name -> directory the session was created in."""
try:
r = subprocess.run(["tmux", "ls", "-F", "#{session_name}\t#{session_path}"],
capture_output=True, text=True)
except Exception:
return {}
if r.returncode != 0:
return {}
result = {}
for line in r.stdout.splitlines():
name, _, path = line.partition("\t")
result[name] = path
return result
def short_path(path: str) -> str:
"""Abbreviate $HOME to ~ for display."""
if not path:
return "?"
home = str(HOME)
if path == home:
return "~"
if path.startswith(home + "/"):
return "~" + path[len(home):]
return path
def resolve_project_dir(p: str) -> Optional[Path]:
"""Same lookup order as claude-tmux: literal path, then under CLAUDE_ROOT, then CLAUDE_ROOT/projects."""
for cand in (Path(p).expanduser(), CLAUDE_ROOT / p, CLAUDE_ROOT / "projects" / p):
if cand.is_dir():
return cand.resolve()
return None
def topic_in_use(topic: str, live: set[str]) -> bool:
# tmux rewrites '.' and ':' in session names to '_', so compare the stored form.
return topic in live or topic.replace(".", "_").replace(":", "_") in live
# ── Folder picker ────────────────────────────────────────────────────────────
def pick_folder_gui(start: Path) -> Optional[Path]:
"""GTK folder chooser via zenity (has a Create Folder button). None if cancelled or unavailable."""
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return None
if subprocess.run(["which", "zenity"], capture_output=True).returncode != 0:
return None
r = subprocess.run(
["zenity", "--file-selection", "--directory",
"--title=Select a folder for the new Claude session",
f"--filename={start}/"],
capture_output=True, text=True,
)
if r.returncode == 0 and r.stdout.strip():
return Path(r.stdout.strip())
return None
def pick_folder_tui(start: Path, page_size: int = 15) -> Optional[Path]:
"""Terminal folder browser with new-folder support. Returns None if cancelled."""
cur = start.resolve() if start.is_dir() else HOME
flt = ""
page = 0
show_hidden = False
while True:
try:
subdirs = sorted(
(d for d in cur.iterdir()
if d.is_dir() and (show_hidden or not d.name.startswith("."))),
key=lambda d: d.name.lower(),
)
except PermissionError:
subdirs = []
warn(f"Cannot read {cur}")
if flt:
subdirs = [d for d in subdirs if flt.lower() in d.name.lower()]
pages = max(1, -(-len(subdirs) // page_size))
page = min(page, pages - 1)
shown = subdirs[page * page_size:(page + 1) * page_size]
os.system("clear 2>/dev/null || true")
print(f"\n{BOLD}Select folder{NC} {CYAN}{short_path(str(cur))}{NC}")
print("─" * 50)
if flt:
print(f" {DIM}filter: {flt!r}{NC}")
if not shown:
print(f" {DIM}(no subfolders){NC}")
for i, d in enumerate(shown, 1):
print(f" {CYAN}{i:>2}){NC} {d.name}/")
if pages > 1:
print(f" {DIM}page {page + 1}/{pages}{NC}")
print(f"\n {DIM}number = open · Enter or s = select this folder · u = up · n = new folder{NC}")
print(f" {DIM}/text = filter · > < = page · h = hidden ({'on' if show_hidden else 'off'}) · ~ or /path = jump · q = cancel{NC}")
try:
raw = input(" > ").strip()
except (KeyboardInterrupt, EOFError):
print()
return None
if raw in ("", "s"):
return cur
elif raw == "q":
return None
elif raw == "u":
cur, flt, page = cur.parent, "", 0
elif raw == ">":
page = min(page + 1, pages - 1)
elif raw == "<":
page = max(page - 1, 0)
elif raw == "h":
show_hidden = not show_hidden
page = 0
elif raw == "n":
try:
name = input(" New folder name: ").strip()
except (KeyboardInterrupt, EOFError):
print()
continue
if not name:
continue
if "/" in name or name in (".", ".."):
print(f" {YELLOW}Name must not contain '/' or be '.'/'..'{NC}")
time.sleep(1.2)
continue
target = cur / name
if target.exists():
print(f" {YELLOW}{name} already exists{NC}")
time.sleep(1.2)
continue
try:
target.mkdir()
except OSError as e:
print(f" {YELLOW}Could not create folder: {e}{NC}")
time.sleep(1.5)
continue
cur, flt, page = target, "", 0
elif raw.startswith("/") and len(raw) > 1 and not Path(raw).is_dir():
flt, page = raw[1:], 0
elif raw.startswith(("/", "~")):
dest = Path(raw).expanduser()
if dest.is_dir():
cur, flt, page = dest.resolve(), "", 0
else:
print(f" {YELLOW}Not a folder: {raw}{NC}")
time.sleep(1.2)
elif raw.isdigit() and 1 <= int(raw) <= len(shown):
cur, flt, page = shown[int(raw) - 1], "", 0
else:
print(f" {YELLOW}Unknown choice: {raw!r}{NC}")
time.sleep(1)
def pick_folder(start: Path) -> Optional[Path]:
return pick_folder_gui(start) or pick_folder_tui(start)
def prompt_topic(folder: Path, live: set[str]) -> Optional[str]:
"""Topic defaults to the folder name; if that is taken, the user must supply a unique one."""
default = title_case(folder.name)
taken = topic_in_use(default, live)
if taken:
print(f" {YELLOW}Topic '{default}' is already in use — enter a unique topic.{NC}")
while True:
try:
if taken:
topic = input(" Topic: ").strip()
else:
topic = input(f" Topic [{BOLD}{default}{NC}]: ").strip() or default
except (KeyboardInterrupt, EOFError):
print()
return None
if not topic:
print(f" {YELLOW}A topic is required.{NC}")
elif topic_in_use(topic, live):
print(f" {YELLOW}Topic '{topic}' is already in use — enter a different one.{NC}")
elif any(c in topic for c in ".:"):
print(f" {YELLOW}Topic must not contain '.' or ':' (tmux rewrites them).{NC}")
else:
return topic
# ── Session file parsing ─────────────────────────────────────────────────────
def read_jsonl_header(path: Path, max_lines: int = 30) -> dict:
@@ -589,24 +773,38 @@ def cmd_menu(_args=None) -> None:
if not options:
print(f"\n {DIM}No sessions.{NC}")
else:
reg_dirs = {e.get("tmux_name"): e.get("project_dir", "") for e in registry.get("sessions", [])}
tmux_dirs = live_session_paths()
resumable_names = {
id(d): Path(d.get("project_dir", "")).name or d.get("project", "?")
for k, d in options if k == "resumable"
}
name_w = min(36, max([len(d["tmux_name"]) for k, d in options if k == "live"]
+ [len(n) for n in resumable_names.values()] + [7]))
if live:
print(f"\n {BOLD}Live:{NC}")
for i, (kind, data) in enumerate(options, 1):
print(f" {DIM}{'Session':<{name_w}} Location{NC}")
for idx, (kind, data) in enumerate(options, 1):
if kind != "live":
continue
idx = options.index(("live", data)) + 1
print(f" {CYAN}{idx:>2}){NC} {GREEN}●{NC} {data['tmux_name']}")
n = data["tmux_name"]
loc = short_path(reg_dirs.get(n) or tmux_dirs.get(n, ""))
print(f" {CYAN}{idx:>2}){NC} {GREEN}●{NC} {n:<{name_w}} {DIM}{loc}{NC}")
if resumable:
print(f"\n {BOLD}Resumable:{NC}")
for i, (kind, data) in enumerate(options, 1):
print(f" {DIM}{'Session':<{name_w}} {'Location':<40} Details{NC}")
for idx, (kind, data) in enumerate(options, 1):
if kind != "resumable":
continue
proj = Path(data.get("project_dir", "")).name or data.get("project", "?")
proj = resumable_names[id(data)]
sid = (data.get("session_id") or "")[:8]
ts = (data.get("last_activity") or data.get("spawned_at") or "?")[:16]
profile = data.get("profile", "?")
print(f" {CYAN}{i:>2}){NC} {DIM}○{NC} {proj:<28} {DIM}{profile} · {sid} · {ts}{NC}")
loc = short_path(data.get("project_dir", ""))
print(f" {CYAN}{idx:>2}){NC} {DIM}○{NC} {proj:<{name_w}} "
f"{loc:<40} {DIM}{profile} · {sid} · {ts}{NC}")
print(f"\n {CYAN} r){NC} Recover sessions from restart (--days 7)")
print(f" {CYAN} n){NC} New session")
@@ -633,20 +831,33 @@ def cmd_menu(_args=None) -> None:
try:
if default_dir:
print(f" Found CLAUDE.md in {BOLD}{default_dir}{NC}")
project = input(
f" Project [{BOLD}{default_dir.name}{NC}] (Enter = use this, or type another): "
).strip()
if not project:
project = str(default_dir)
ans = input(
f" Folder [{BOLD}{short_path(str(default_dir))}{NC}] "
f"(Enter = use this, b = browse, or type a project/path): "
).strip() or str(default_dir)
else:
project = input(" Project name: ").strip()
ans = input(" Folder (b = browse, or type a project/path): ").strip()
except (KeyboardInterrupt, EOFError):
print()
continue
if not project:
if not ans:
continue
if ans.lower() == "b":
folder = pick_folder(default_dir or Path.cwd())
if folder is None:
continue
print(f" Folder: {BOLD}{short_path(str(folder))}{NC}")
else:
folder = resolve_project_dir(ans)
if folder is None:
print(f" {YELLOW}Folder not found: {ans}{NC}")
time.sleep(1.5)
continue
topic = prompt_topic(folder, list_tmux_sessions())
if topic is None:
continue
import types
a = types.SimpleNamespace(project=project, mode=None, profile=None, name=None, attach=True)
a = types.SimpleNamespace(project=str(folder), mode=None, profile=None, name=topic, attach=True)
print()
cmd_spawn(a) # exec's claude-tmux --attach; replaces this process