Metadata-Version: 2.5
Name: sessionmux
Version: 0.1.0
Summary: Two-panel, keyboard-only switcher for Claude Code sessions in one terminal
Project-URL: Homepage, https://gitlab.com/cardona-oss/sessionmux
Project-URL: Repository, https://gitlab.com/cardona-oss/sessionmux
Project-URL: Issues, https://gitlab.com/cardona-oss/sessionmux/-/issues
Project-URL: Changelog, https://gitlab.com/cardona-oss/sessionmux/-/blob/main/CHANGELOG.md
Project-URL: Documentation, https://cardona-oss.gitlab.io/sessionmux/
Author-email: Jorge Cardona <jorgeecardona@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: claude,claude-code,multiplexer,session,terminal,textual,tui
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Terminals
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: textual-tty==0.4.0
Requires-Dist: textual==8.2.8
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9.5; extra == 'docs'
Requires-Dist: mkdocs>=1.6; extra == 'docs'
Requires-Dist: mkdocstrings[python]>=0.27; extra == 'docs'
Description-Content-Type: text/markdown

# sessionmux

Two-panel switcher for Claude Code sessions in one terminal, keyboard-only,
emacs-styled. Claude runs inside the app; no tmux.

```
┌──────────────────────────────────┬──────────────────────────┐
│ the Claude session you work in   │    Age  Session          │
│                                  │ ▶  1m   timbre           │
│ > ...                            │         ~/stuff/projects │
│                                  │ ⏳ 2h   crm              │
└──────────────────────────────────┴──────────────────────────┘
        main (left)                   list (right, as wide as its content)
```

## Run

```bash
sessionmux                       # conversations of the current directory
sessionmux ~/stuff/projects      # or of another directory
```

`a` in the list switches to the conversations of every directory and back.

Install with `pip install sessionmux` (or `uv tool install sessionmux`), then run
`sessionmux`. The `textual` / `textual-tty` dependencies come with it. Until
2026-10-19 the app was called `claude-sessions`.

## Keys

Anywhere (also while Claude has the keys):

| Key | Action |
|-----|--------|
| `Alt-s` | Show the list (if hidden) and focus it |
| `Alt-z` | Hide / show the list |

In the list:

| Key | Action |
|-----|--------|
| `Enter` | Show the conversation in the main pane and focus it (first time: start its Claude) |
| `n` | New conversation in this directory |
| `a` | List this directory / all directories |
| `Tab` | Back to Claude without switching |
| `C-n` `C-p` / `j` `k` / arrows | Move |
| `C-v` / `M-v` | Page down / up |
| `M-<` / `M->` (or `<` / `>`) | Top / bottom |
| `g` / `t` / `r` | Set group / tags (comma-separated) / label |
| `R` | Reload the list |
| `z` | Hide the list |
| `x` | Close the conversation: its Claude ends, the transcript stays, not reopened next start (asks while it runs) |
| `d` / `D` | Delete the conversation (asks `yes or no`; see below) |
| `q` / `C-q` | Quit: close every Claude opened here (asks `yes or no` while one runs) |
| `?` | Show these keys (any key closes them) |

Inside Claude every other key is Claude's (Ctrl+C, Ctrl+Q, Tab, `←`, ...).
App keys are Alt + a letter only: Textual reads Alt + a digit, `<` or `>` as
other characters.

Each row is one conversation: its title, and under it (dimmed) the directory
it works in now and its git branch. The mode-line shows the conversation in the
main pane and its directory; with nothing shown, the directory (or "all
directories") and the number of conversations. With all directories the header
says `Session · all`. The list is as wide as its longest line, at least
36 columns and at most half the window.

The list's border and cursor are in the accent colour only while the list has
the keys; when Claude has them, both are dimmed. Ages and order stay current
(rescanned with every poll; the cursor stays on its conversation). After a
delete the cursor goes to the row above. When the Claude in the main pane
ends, the keys go back to the list.

Marks: `▶` shown in the main pane · `⏳` waiting for input · `⚙` working ·
`●` running (here or elsewhere) · `○` open at the last run, not started yet.

## How it works

- **Hosting** — every Claude you open runs in its own pseudo-terminal inside
  the app, drawn by a `textual-tty` terminal widget (`hosting.ClaudePane`). All
  panes stay mounted in a `ContentSwitcher`; the hidden ones keep running, so
  switching back is instant (~100 ms). The first open starts Claude (~2 s):
  `claude attach <id>` for a background session, else `claude --resume <id>`,
  in the list's directory (another directory's conversation: in its own
  directory, via `sh -c 'cd "$0" && exec "$@"'`, since textual-tty has no
  working-directory option). Quitting closes every pane's pseudo-terminal, and
  each Claude exits. There is no detach: the transcripts stay, and `Enter`
  resumes a conversation next time.
- **`←` inside Claude** — Claude moves the conversation to a new background
  session (the old transcript gets a `continued-in` line) and can show any
  other agent in the same pane. A `continued-in` chain is one row. Claude sets
  the terminal title to the shown session's name; the pane reports each new
  title, the app polls `claude agents --json` and matches the title once
  against that snapshot (never against one taken before the title: it may
  still carry old names), re-tags the pane, rescans when the session is new,
  and moves the list cursor to it. A title that names no listed agent yet (a
  continuation just made) is tried again with the next polls. The title is
  observed behaviour (Claude 2.1.302), not a documented interface; if two
  agents share the name, the pane keeps its tag.
- **Cursor** — Claude uses the terminal's real cursor, so the app does too:
  textual-tty's painted cursor cell is off, and while a Claude pane has the
  keys the app shows your terminal's own cursor at Claude's cursor position
  (hidden while the list or a prompt has the keys). Textual has no public call
  for that, so the app writes the show/hide codes itself; Textual is pinned to
  8.2.8 for it.
- **New conversation (`n`)** — starts plain `claude` in the list's directory
  (also with all directories listed). Claude writes no transcript before the
  first message, but `claude agents` lists the new process at once with its
  session id: the app matches the pane's process id to learn it (a poll 3 s
  after the start, then every 10 s). Until then the mode-line says
  `▶ new conversation`. The row appears with the first scan after the first
  message, and the cursor goes to it.
- **All directories (`a`)** — every folder under `~/.claude/projects`. The
  first read takes a few seconds (~4 s for ~1.9 GB here, in the background
  thread; the mode-line says `reading transcripts…`, typing stays at ~35 ms).
  A conversation from another directory starts its Claude in that directory:
  resumed from elsewhere, Claude loads it but works in the wrong directory.
  Claude keeps a transcript in the folder named after the real path of the
  directory it ran in; older transcripts can record a `cwd` that does not
  match their folder, so the app uses the recorded `cwd` when its real path
  gives the folder name, else an existing directory found from the folder
  name. When no such directory exists any more, `Enter` says so and opens
  nothing. This directory's list also reads the folder of its real path (the
  two differ for a symlinked path).
- **Claude exited** (`/exit`) — the pane keeps its last screen and the
  mode-line says `(ended)`; `Enter` on the row starts it again.
- **Delete (`d` / `D`)** — Claude has no per-session delete (`claude rm` keeps
  the transcript; `claude purge` deletes a whole directory). `D` refuses while
  the conversation runs in another terminal, and when `claude agents` fails
  (then where it runs is not known); one delete runs at a time. Otherwise it
  runs `claude stop` +
  `claude rm` on its background sessions (stops if Claude refuses, e.g. a
  worktree with unpushed commits), closes its panes, waits for those processes
  to exit, then moves every transcript of the conversation to
  `~/.local/share/sessionmux/trash/<time>-<id>/` (`$XDG_DATA_HOME`).
  `RESTORE.txt` there lists where each file came from; move them back to undo.
  Empty the trash with `rm -rf ~/.local/share/sessionmux/trash`.
- **Open conversations across runs** — what is open (and which one is shown,
  and `a`) is written at every change to
  `$XDG_STATE_HOME/sessionmux/open/<encoded list dir>.json` (default
  `~/.local/state`; the XDG spec's place for "current state of the application
  that can be reused on a restart"; directories made with mode 0700; atomic
  write), so it is current even if the computer dies. The next start, once the
  first scan and poll are in, starts again only the conversation that was
  shown (the keys stay in the list) and marks the others `○`; they start on
  `Enter` (each Claude costs ~2 s and a few hundred MB). Not started: one that
  runs in another terminal now. Dropped from it: a conversation you end
  (`/exit`), close (`x`) or delete; `q` keeps them all. A new conversation is
  kept once it has its session id. The conversations themselves never depend
  on this file: Claude writes every message to its transcript as it goes.
- **Memory** — each open conversation is one running Claude until `q` or `x`.

Files (under `src/sessionmux/`):

- `sessions.py` — reads Claude's transcripts
  (`~/.claude/projects/<encoded-cwd>/<id>.jsonl`, an internal format) into
  conversations; joins your labels/groups/tags; finds where to start a
  conversation's Claude. Transcripts reach hundreds of MB: only the few
  relevant lines are parsed, the newest `cwd` is read from the file's end,
  unchanged files come from a cache, and a file that grew is read only from
  where the last read stopped (Claude only appends). The app scans in a
  background thread, so the list never freezes.
- `agents.py` — live state from `claude agents --json` (read-only, in a
  background thread every 10 s, after you open a conversation and after a new
  title; one poll at a time, and asked while one runs it polls once more after
  it, so snapshots arrive in order; each call costs ~0.4 s CPU; a failed call
  keeps the last snapshot), the title → session matcher, and stop/rm/wait for
  delete.
- `hosting.py` — `Target` (how to open a conversation) and `ClaudePane`.
- `state.py` — `OpenState`, the open conversations kept across runs (XDG state dir).
- `xdgdirs.py` — the XDG data and state directories, and making them (0700).
- `legacy.py` — moves the files of earlier versions to where they live now.
- `app.py` — the Textual app.

The app's own files follow the XDG base directories (`xdgdirs.py`; a relative
`$XDG_*` value is ignored, as the spec says; new directories are mode 0700):

| What | Where |
|---|---|
| Labels, groups, tags (keyed by session id) | `$XDG_DATA_HOME/sessionmux/meta.json` (`~/.local/share/...`) |
| Trash | `$XDG_DATA_HOME/sessionmux/trash/` |
| Open conversations, per list directory | `$XDG_STATE_HOME/sessionmux/open/` (`~/.local/state/...`) |

Each label change is a read-modify-write under a lock (`meta.json.lock`,
flock: also between two running apps) and an atomic write (temp file +
rename). Claude's own files under `~/.claude` are only read, and moved by
delete. Earlier versions kept them elsewhere: until 2026-10-18 the labels and
the trash in `~/.claude` (`session-switcher-meta.json`,
`session-switcher-trash/`), until 2026-10-19 under the old name
(`~/.local/share/claude-sessions/`, `~/.local/state/claude-sessions/`). The
app moves them once at start (`legacy.py`; a file already in the new place
wins; each trash folder's `RESTORE.txt` gets its new paths) and says what it
moved.

On quit, Textual cancels the background workers but cannot stop their
threads: a scan stops at the next file, and nothing a worker sends after that
is applied.

Crashes of the app are logged to `logs/switcher.err`.

## Tests

```bash
uv run pytest -q
```

- `tests/test_sessions.py` — transcript parsing (also appended and half-written lines),
  `continued-in` chains, rows, every directory, where a conversation starts, sidecar, trash
  (hand-written transcripts in a temporary `CLAUDE_CONFIG_DIR`).
- `tests/test_agents.py` — `claude agents --json` parsing (faked), the title matcher,
  stop/rm, waiting for processes (zombies count as gone).
- `tests/test_app.py` — how Enter opens a row, the list width, the list UI, delete, `a`.
- `tests/test_state.py` — the state file: XDG paths, 0700 directories, shape, atomic write.
- `tests/test_legacy.py` — moving the files of earlier versions.

Every test runs with `CLAUDE_CONFIG_DIR`, `XDG_DATA_HOME` and `XDG_STATE_HOME` in
a temporary directory (autouse fixtures in `tests/conftest.py`), never the real ones.
- `tests/test_hosting.py` — the panes with a fake `claude` in a real pseudo-terminal:
  start, switch back (same process), keys reach Claude, Alt-z/Alt-s/Tab, Ctrl+Q inside
  Claude does not quit, `q` closes every Claude, restart after exit, ← retagging,
  delete closes the pane, no process left behind, `n` (id from the process, the row
  appears, the cursor follows), another directory's conversation runs there.
