diff --git a/claude-mux/README.md b/claude-mux/README.md new file mode 100644 index 0000000..806c3fc --- /dev/null +++ b/claude-mux/README.md @@ -0,0 +1,114 @@ +# claude-mux + +WARNING: this is vibe coded slop + +A tiny tmux-backed session manager for [Claude Code](https://claude.com/claude-code). + +`claude-mux` runs an **isolated tmux server** (its own socket) whose only job is +to host `claude` processes. Because it uses a dedicated socket, it nests cleanly +inside another tmux session without any binding or session conflict — that is the +whole point of the separate socket. + +## Usage + +```sh +cd ~/projects/my-thing +claude-mux +``` + +This starts (or re-attaches) the Claude session for the current directory. Inside +a session there are exactly two chords plus a detach, under the `C-x` prefix: + +| Key | Action | +| ------- | ----------------------------------------------------------------- | +| `C-x l` | Float a picker of **every** Claude session for this project | +| `C-x n` | Start a fresh Claude session in a new window (current keeps going) | +| `C-x d` | Detach — everything keeps running in the background | +| `C-x C-x` | Send a literal `C-x` through to Claude | + +The inner tmux loads **none** of your personal tmux config and binds nothing but +the keys above, so from inside it feels like you are talking to `claude` directly. + +When a Claude session ends (you quit `claude`), claude-mux **detaches** and drops +you back to your shell. Any other sessions keep running in the background and can +be reattached (`claude-mux`) or reopened from the picker. To actually terminate +sessions, use `claude-mux kill` or `x` in the picker. + +### The session picker (`C-x l`) + +The picker lists every Claude session recorded for the current project directory — +not just the ones currently open in a window. Brand-new sessions that have not +exchanged any messages yet are omitted. The list scrolls when it is longer than +the popup, and the popup follows your terminal's light/dark theme. For each +session it shows: + +- a status glyph: + - **●** green — *running* (Claude is working), + - **?** amber — *waiting for you* (Claude asked something / needs attention), + - **○** blue — *open* (idle in a window, ready for input), + - **·** dim — *closed* (transcript only, not open in any window); +- the session title (Claude's AI-generated title, falling back to the last prompt), +- the message count and how long ago it was last active. + +Each session is shown on two lines (title, then status/meta) so it stays readable +in narrow popups, and the list and help text scroll/wrap to fit small windows. +Sessions needing attention float to the top (waiting → running → open → closed). + +The live states (running / waiting / open) are reported by Claude Code hooks +(`claude-mux hook --status …`), wired up in `modules/cli/home.nix`. + +| Key | Action | +| -------------- | ----------------------------------------------- | +| `↑`/`↓`, `j`/`k` | Move the selection | +| `enter` | Open the selected session | +| `n` | Start a fresh session | +| `x` | Kill the selected running session | +| `ctrl+a` | Toggle between this project and **all** projects | +| `r` | Refresh | +| `q` / `esc` | Cancel | + +Selecting a session that is already running just jumps to its window; selecting an +idle one resumes it (`claude --resume`) in a new window. With `ctrl+a` (or +`claude-mux list --all`) the picker shows sessions from every project, labelled by +project; resuming one there opens (or switches to) that project's session. + +## How it works + +- **Sessions** are read straight from Claude's transcript storage + (`$CLAUDE_CONFIG_DIR/projects//.jsonl`), so the list is + authoritative regardless of what is open in tmux. +- **Running status** is tracked by launching every window through + `claude-mux run`, which assigns a known session id (`claude --session-id`) and + records it as a tmux window option (`@claude_session_id`). That id is what lets + the picker tell a live session apart and jump straight to its window. +- Each project directory gets its own tmux session on the shared socket, named + after the directory (basename + a short path hash). + +## Configuration + +| Variable | Meaning | Default | +| ------------------- | ---------------------------------------------------- | ------------ | +| `CLAUDE_MUX_SOCKET` | tmux socket name for the isolated server | `claude-mux` | +| `CLAUDE_CONFIG_DIR` | Claude's config dir (where transcripts live) | Claude's default | + +## Commands + +``` +claude-mux Start or attach the session for the current directory +claude-mux list Interactive session picker (used by the C-x l chord) +claude-mux list --all Picker across every project (also toggled with ctrl+a) +claude-mux list --dump Print the session listing as plain text (scripting/debug) +claude-mux new Start a fresh Claude session in the current directory +claude-mux kill Kill the running sessions for the current directory +claude-mux kill --all Kill every running session across all projects +claude-mux run Internal launcher used by the tmux windows +``` + +## Build + +```sh +go build -o claude-mux . +``` + +In this flake it is packaged as `pkgs.claude-mux` (see `overlays/default.nix`) and +installed via `modules/cli/home.nix`. diff --git a/claude-mux/go.mod b/claude-mux/go.mod new file mode 100644 index 0000000..2c6eb84 --- /dev/null +++ b/claude-mux/go.mod @@ -0,0 +1,28 @@ +module claude-mux + +go 1.26.5 + +require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/claude-mux/go.sum b/claude-mux/go.sum new file mode 100644 index 0000000..4789639 --- /dev/null +++ b/claude-mux/go.sum @@ -0,0 +1,43 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/claude-mux/internal/manager/manager.go b/claude-mux/internal/manager/manager.go new file mode 100644 index 0000000..87101b2 --- /dev/null +++ b/claude-mux/internal/manager/manager.go @@ -0,0 +1,91 @@ +// Package manager ties transcript listing and the isolated tmux server together +// into a single enriched view used by the picker and the CLI. +package manager + +import ( + "sort" + + "claude-mux/internal/session" + "claude-mux/internal/state" + "claude-mux/internal/tmux" +) + +// Entry is a session augmented with its live tmux location, when running. +type Entry struct { + session.Session + // Target is the tmux "session:window" hosting this session, empty when the + // session is not currently open in the isolated server. + Target string + // ProjectDir is the directory this session belongs to (its own cwd), used to + // resume it in the right project even when listing across all projects. + ProjectDir string +} + +// Load lists sessions and marks which are currently running by consulting the +// tmux windows that claude-mux tagged with their session id. When all is true it +// spans every project; otherwise it is scoped to dir. Brand-new sessions that +// have not written a transcript yet are intentionally omitted. +func Load(dir string, all bool, srv *tmux.Server) ([]Entry, error) { + var ( + sessions []session.Session + err error + ) + if all { + sessions, err = session.ListAll() + } else { + sessions, err = session.List(dir) + } + if err != nil { + return nil, err + } + + running := srv.RunningSessions() // id -> target ("slug:window") + + entries := make([]Entry, 0, len(sessions)) + for _, s := range sessions { + e := Entry{Session: s, ProjectDir: projectDirOf(s, dir)} + if target, ok := running[s.ID]; ok { + // Open in a window; the substate (running/questions/idle) comes from + // what the session last reported through its hooks. + e.Status = session.ParseStatus(state.Get(s.ID)) + e.Target = target + } else { + e.Status = session.StatusClosed + } + entries = append(entries, e) + } + + // Order by attention: questions first, then running, idle, closed; then by + // most recent activity within each group. + sort.SliceStable(entries, func(i, j int) bool { + pi, pj := statusRank(entries[i].Status), statusRank(entries[j].Status) + if pi != pj { + return pi > pj + } + return entries[i].Updated.After(entries[j].Updated) + }) + return entries, nil +} + +// statusRank orders statuses so the ones needing attention float to the top. +func statusRank(s session.Status) int { + switch s { + case session.StatusQuestions: + return 3 + case session.StatusRunning: + return 2 + case session.StatusIdle: + return 1 + default: // closed + return 0 + } +} + +// projectDirOf returns the directory a session belongs to, preferring the cwd +// recorded in its transcript and falling back to the queried dir. +func projectDirOf(s session.Session, dir string) string { + if s.CWD != "" { + return s.CWD + } + return dir +} diff --git a/claude-mux/internal/paths/paths.go b/claude-mux/internal/paths/paths.go new file mode 100644 index 0000000..b8ce1a2 --- /dev/null +++ b/claude-mux/internal/paths/paths.go @@ -0,0 +1,70 @@ +// Package paths resolves the on-disk locations claude-mux depends on: Claude's +// config/session storage and claude-mux's own cache/config files. +package paths + +import ( + "os" + "path/filepath" + "strings" +) + +// ClaudeConfigDir returns the directory Claude Code stores its state in. +// It mirrors Claude Code's own resolution order: $CLAUDE_CONFIG_DIR wins, +// then $XDG_CONFIG_HOME/claude, then ~/.claude. +func ClaudeConfigDir() string { + if d := os.Getenv("CLAUDE_CONFIG_DIR"); d != "" { + return d + } + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "claude") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".claude") +} + +// ProjectsDir is where Claude keeps per-project session transcripts. +func ProjectsDir() string { + return filepath.Join(ClaudeConfigDir(), "projects") +} + +// EncodeProjectPath converts an absolute filesystem path into the directory +// name Claude uses under projects/. Claude replaces every character that is not +// an ASCII letter or digit with a dash, e.g. /home/me/projects/flake -> +// -home-me-projects-flake. +func EncodeProjectPath(dir string) string { + var b strings.Builder + b.Grow(len(dir)) + for _, r := range dir { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteRune('-') + } + } + return b.String() +} + +// SessionDir returns the directory holding transcripts for the given project +// directory. +func SessionDir(projectDir string) string { + return filepath.Join(ProjectsDir(), EncodeProjectPath(projectDir)) +} + +// CacheDir is where claude-mux writes generated files (the tmux config). +func CacheDir() string { + if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" { + return filepath.Join(xdg, "claude-mux") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".cache", "claude-mux") +} + +// StateDir is where claude-mux keeps per-session runtime state (the status a +// Claude session reports through hooks). +func StateDir() string { + if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" { + return filepath.Join(xdg, "claude-mux") + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".local", "state", "claude-mux") +} diff --git a/claude-mux/internal/session/session.go b/claude-mux/internal/session/session.go new file mode 100644 index 0000000..594d85f --- /dev/null +++ b/claude-mux/internal/session/session.go @@ -0,0 +1,236 @@ +// Package session reads Claude Code's on-disk transcripts and turns them into a +// listing of sessions for a given project directory. +package session + +import ( + "bufio" + "encoding/json" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "claude-mux/internal/paths" +) + +// Status describes the live state of a Claude session. +type Status int + +const ( + // StatusClosed means the session is not open in any window (transcript only). + StatusClosed Status = iota + // StatusIdle means the session is open and waiting for input (at rest). + StatusIdle + // StatusRunning means Claude is actively working on a response. + StatusRunning + // StatusQuestions means Claude is waiting for the user to answer something. + StatusQuestions +) + +func (s Status) String() string { + switch s { + case StatusRunning: + return "running" + case StatusQuestions: + return "questions" + case StatusIdle: + return "idle" + default: + return "closed" + } +} + +// Open reports whether the session is open in a window (any non-closed state). +func (s Status) Open() bool { return s != StatusClosed } + +// ParseStatus maps a status word (as written by the hook) to an open Status, +// defaulting to StatusIdle for anything unrecognised. +func ParseStatus(word string) Status { + switch word { + case "running": + return StatusRunning + case "questions", "question", "waiting": + return StatusQuestions + default: + return StatusIdle + } +} + +// Session is a single Claude conversation transcript. +type Session struct { + ID string // transcript uuid (filename without extension) + Path string // absolute path to the .jsonl transcript + Title string // human friendly title + CWD string // working directory recorded in the transcript + Updated time.Time // timestamp of the most recent activity + Created time.Time // timestamp of the first activity + Messages int // number of user/assistant messages + Status Status // running/idle, populated by the caller + RunningPID int // pid of the process holding it open, when running +} + +// line is the subset of a transcript record we care about. Decoding only these +// fields keeps parsing cheap even for large transcripts. +type line struct { + Type string `json:"type"` + AiTitle string `json:"aiTitle"` + LastPrompt string `json:"lastPrompt"` + Summary string `json:"summary"` + CWD string `json:"cwd"` + Timestamp time.Time `json:"timestamp"` +} + +// List returns every Claude session recorded for projectDir, most recently +// active first. Status is left as StatusIdle; callers layer that on top. +func List(projectDir string) ([]Session, error) { + sessions, err := listDir(paths.SessionDir(projectDir)) + if err != nil { + return nil, err + } + sortByRecent(sessions) + return sessions, nil +} + +// ListAll returns every Claude session across every project, most recently +// active first. Each session's CWD (read from the transcript) identifies which +// project it belongs to. +func ListAll() ([]Session, error) { + projects, err := os.ReadDir(paths.ProjectsDir()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var all []Session + for _, p := range projects { + if !p.IsDir() { + continue + } + sessions, err := listDir(filepath.Join(paths.ProjectsDir(), p.Name())) + if err != nil { + continue + } + all = append(all, sessions...) + } + sortByRecent(all) + return all, nil +} + +// listDir parses every *.jsonl transcript directly inside dir. +func listDir(dir string) ([]Session, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var sessions []Session + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { + continue + } + s, err := parse(filepath.Join(dir, e.Name())) + if err != nil { + continue // skip unreadable/corrupt transcripts rather than fail the whole list + } + sessions = append(sessions, s) + } + return sessions, nil +} + +func sortByRecent(sessions []Session) { + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].Updated.After(sessions[j].Updated) + }) +} + +// parse reads a single transcript, extracting metadata for the listing. +func parse(path string) (Session, error) { + f, err := os.Open(path) + if err != nil { + return Session{}, err + } + defer f.Close() + + info, _ := f.Stat() + s := Session{ + ID: strings.TrimSuffix(filepath.Base(path), ".jsonl"), + Path: path, + } + if info != nil { + s.Updated = info.ModTime() + } + + var aiTitle, lastPrompt, summary string + + sc := bufio.NewScanner(f) + // Transcript lines can be long (embedded content); grow the buffer. + sc.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for sc.Scan() { + raw := sc.Bytes() + if len(raw) == 0 { + continue + } + var l line + if err := json.Unmarshal(raw, &l); err != nil { + continue + } + switch l.Type { + case "ai-title": + if l.AiTitle != "" { + aiTitle = l.AiTitle + } + case "last-prompt": + if l.LastPrompt != "" { + lastPrompt = l.LastPrompt + } + case "summary": + if l.Summary != "" { + summary = l.Summary + } + case "user", "assistant": + s.Messages++ + } + if l.CWD != "" && s.CWD == "" { + s.CWD = l.CWD + } + if !l.Timestamp.IsZero() { + if s.Created.IsZero() || l.Timestamp.Before(s.Created) { + s.Created = l.Timestamp + } + if l.Timestamp.After(s.Updated) { + s.Updated = l.Timestamp + } + } + } + if err := sc.Err(); err != nil && err != io.EOF { + return Session{}, err + } + + s.Title = firstNonEmpty(aiTitle, summary, firstLine(lastPrompt), "(untitled)") + return s, nil +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +// firstLine returns the first non-empty line of s, collapsed to single spaces. +func firstLine(s string) string { + for _, ln := range strings.Split(s, "\n") { + ln = strings.TrimSpace(ln) + if ln != "" { + return strings.Join(strings.Fields(ln), " ") + } + } + return "" +} diff --git a/claude-mux/internal/state/state.go b/claude-mux/internal/state/state.go new file mode 100644 index 0000000..52f7f2d --- /dev/null +++ b/claude-mux/internal/state/state.go @@ -0,0 +1,48 @@ +// Package state persists the runtime status a Claude session reports through +// hooks (running / questions / idle) so the picker can display it. Each session +// gets one small file named by its id under the state directory. +package state + +import ( + "os" + "path/filepath" + "strings" + + "claude-mux/internal/paths" +) + +func dir() string { return filepath.Join(paths.StateDir(), "status") } + +func file(id string) string { return filepath.Join(dir(), id) } + +// Set records status for the session id, overwriting any previous value. +func Set(id, status string) error { + if id == "" { + return nil + } + if err := os.MkdirAll(dir(), 0o755); err != nil { + return err + } + return os.WriteFile(file(id), []byte(status), 0o644) +} + +// Clear removes any recorded status for the session id. +func Clear(id string) error { + if id == "" { + return nil + } + err := os.Remove(file(id)) + if os.IsNotExist(err) { + return nil + } + return err +} + +// Get returns the recorded status word for id, or "" when none is recorded. +func Get(id string) string { + b, err := os.ReadFile(file(id)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} diff --git a/claude-mux/internal/tmux/tmux.go b/claude-mux/internal/tmux/tmux.go new file mode 100644 index 0000000..859fa69 --- /dev/null +++ b/claude-mux/internal/tmux/tmux.go @@ -0,0 +1,326 @@ +// Package tmux drives a dedicated, isolated tmux server (its own socket) that +// hosts Claude sessions. Using a separate socket means claude-mux can run +// happily nested inside another tmux without any binding or session conflict. +package tmux + +import ( + "crypto/sha1" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + + "claude-mux/internal/paths" +) + +// DefaultSocket is the tmux -L socket name used unless overridden. +const DefaultSocket = "claude-mux" + +// sessionIDOption is the tmux window option that records which Claude session a +// window is running. It is what lets the picker tell running sessions apart and +// jump straight to their window. +const sessionIDOption = "@claude_session_id" + +// Server represents the isolated tmux server on a given socket. +type Server struct { + Socket string + BinPath string // absolute path to the claude-mux binary, baked into bindings +} + +// New returns a Server for the socket named by $CLAUDE_MUX_SOCKET, falling back +// to DefaultSocket. +func New() *Server { + sock := os.Getenv("CLAUDE_MUX_SOCKET") + if sock == "" { + sock = DefaultSocket + } + self, _ := os.Executable() + return &Server{Socket: sock, BinPath: self} +} + +// args prepends the socket selector to a tmux argument list. +func (s *Server) args(rest ...string) []string { + return append([]string{"-L", s.Socket}, rest...) +} + +// run executes a tmux command and returns trimmed stdout. +func (s *Server) run(rest ...string) (string, error) { + cmd := exec.Command("tmux", s.args(rest...)...) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + return "", fmt.Errorf("tmux %s: %s", strings.Join(rest, " "), strings.TrimSpace(string(ee.Stderr))) + } + return "", err + } + return strings.TrimSpace(string(out)), nil +} + +// Slug derives a stable, tmux-safe session name from a project directory: +// its basename plus a short hash of the full path for disambiguation. +func Slug(dir string) string { + base := filepath.Base(dir) + var b strings.Builder + for _, r := range base { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + sum := sha1.Sum([]byte(dir)) + return b.String() + "-" + hex.EncodeToString(sum[:])[:6] +} + +// launchCmd builds the shell-command tmux runs in a window: it goes through +// `claude-mux run` so the window records its session id, then execs Claude. +func (s *Server) launchCmd(dir, resumeID string) string { + q := func(v string) string { return "'" + strings.ReplaceAll(v, "'", `'\''`) + "'" } + cmd := fmt.Sprintf("exec %s run --dir %s", q(s.BinPath), q(dir)) + if resumeID != "" { + cmd += " --resume " + q(resumeID) + } + return cmd +} + +// configPath is where the generated, isolated tmux config lives. +func configPath() string { + return filepath.Join(paths.CacheDir(), "tmux.conf") +} + +// writeConfig (re)generates the isolated tmux configuration. The Claude session +// tmux deliberately loads none of the user's config: the only keys bound are +// claude-mux's own chords under the C-x prefix. binPath and socket are baked in +// so the bindings work regardless of $PATH inside the server. +func writeConfig(binPath, socket string) (string, error) { + if err := os.MkdirAll(paths.CacheDir(), 0o755); err != nil { + return "", err + } + conf := fmt.Sprintf(`# Generated by claude-mux. Do not edit; it is rewritten on launch. +set -g prefix C-x +set -g prefix2 None +set -g status off +set -g mouse off +set -g escape-time 10 +set -g base-index 1 +setw -g pane-base-index 1 +set -g renumber-windows on +set -g default-terminal "tmux-256color" + +# Forward extended keys (CSI-u) so Claude can tell C-h from Backspace, etc. +set -g extended-keys on +set -as terminal-features '*:extkeys' + +# Frame the floating picker with a rounded border so it stands out, but keep its +# interior on the terminal's default colours so it follows the system theme. +set -g popup-border-lines rounded +set -g popup-border-style "fg=blue" +set -g popup-style "bg=default,fg=default" + +# When a Claude window ends (the process exits), detach the client so claude-mux +# exits back to the shell, while any other sessions keep running in the +# background. Popups do not fire this hook, so opening the picker is safe. +set-hook -g window-unlinked "detach-client" + +# Isolated: strip every default binding, then add only our chords. +unbind -a -T prefix +unbind -a -T root + +# C-x C-x sends a literal C-x through to Claude. +bind -T prefix C-x send-prefix +# C-x l : floating list of all Claude sessions for this project. The project is +# resolved from the session environment ($CLAUDE_MUX_PROJECT_DIR, which the popup +# inherits) rather than a format inside the shell-command, because tmux passes +# the shell-command verbatim without expanding #{...}. -d sets the popup's start +# directory (and does expand) as a belt-and-suspenders. +bind -T prefix l display-popup -d "#{@claude_project_dir}" -w 90%% -h 85%% -E "'%s' list --socket '%s'" +# C-x n : start a fresh Claude session in a new window (current keeps running). +bind -T prefix n new-window -c "#{@claude_project_dir}" -n claude "exec '%s' run --dir '#{@claude_project_dir}'" +# C-x d : detach and leave everything running in the background. +bind -T prefix d detach-client +`, binPath, socket, binPath) + + path := configPath() + if err := os.WriteFile(path, []byte(conf), 0o644); err != nil { + return "", err + } + return path, nil +} + +// EnsureSession makes sure the isolated server is running and has a session for +// dir (creating one that launches Claude if needed), records the project +// directory on the session, and refreshes the bindings. It does not attach. +func (s *Server) EnsureSession(dir string) (string, error) { + conf, err := writeConfig(s.BinPath, s.Socket) + if err != nil { + return "", err + } + slug := Slug(dir) + + // Only create the session if it does not already exist. (We deliberately + // avoid `new-session -A`: on an existing session -A behaves like + // attach-session, which tries to grab a terminal and fails here because this + // runs with captured, non-tty stdio.) -f applies our config when this call + // is what first starts the server. + if !s.hasSession(slug) { + if _, err := s.run("-f", conf, "new-session", "-d", "-s", slug, "-c", dir, s.launchCmd(dir, "")); err != nil { + return "", err + } + } + s.tagProject(slug, dir) + // Re-source the config so bindings exist even if the server pre-existed. + _, _ = s.run("source-file", conf) + return slug, nil +} + +// tagProject records the project directory on a session, both as a tmux option +// (for the -c in bindings) and in the session environment (so the popup and any +// launched process resolve the same project and Claude config dir). +func (s *Server) tagProject(slug, dir string) { + _, _ = s.run("set-option", "-t", slug, "@claude_project_dir", dir) + _, _ = s.run("set-environment", "-t", slug, "CLAUDE_MUX_PROJECT_DIR", dir) + _, _ = s.run("set-environment", "-t", slug, "CLAUDE_CONFIG_DIR", paths.ClaudeConfigDir()) +} + +// Attach replaces the current process with a tmux client attached to slug. +func (s *Server) Attach(slug string) error { + tmuxPath, err := exec.LookPath("tmux") + if err != nil { + return err + } + argv := s.args("attach-session", "-t", slug) + return syscall.Exec(tmuxPath, append([]string{tmuxPath}, argv...), os.Environ()) +} + +// RunningSessions returns a map of Claude session id -> tmux target +// ("session:window") for every window currently hosting a session. This is how +// the picker knows what is live and where to jump. +func (s *Server) RunningSessions() map[string]string { + out, err := s.run("list-windows", "-a", "-F", + "#{session_name}:#{window_index}\t#{"+sessionIDOption+"}") + if err != nil { + return nil + } + result := make(map[string]string) + for _, ln := range strings.Split(out, "\n") { + if ln == "" { + continue + } + f := strings.SplitN(ln, "\t", 2) + if len(f) != 2 || f[1] == "" { + continue + } + result[f[1]] = f[0] + } + return result +} + +// TagWindow records sessionID on the window containing paneTarget (usually +// $TMUX_PANE), so RunningSessions can find it later. +func (s *Server) TagWindow(paneTarget, sessionID string) error { + _, err := s.run("set-option", "-w", "-t", paneTarget, sessionIDOption, sessionID) + return err +} + +// hasSession reports whether a session named slug exists. +func (s *Server) hasSession(slug string) bool { + _, err := s.run("has-session", "-t", slug) + return err == nil +} + +// Focus brings the window at target ("session:window") to the foreground, +// switching the current client to that session first so it also works for a +// session belonging to another project (the --all case). +func (s *Server) Focus(target string) error { + sess := target + if i := strings.IndexByte(target, ':'); i >= 0 { + sess = target[:i] + } + _, _ = s.run("switch-client", "-t", sess) + _, err := s.run("select-window", "-t", target) + return err +} + +// ResumeInProject opens (or creates the project session for) a window that +// resumes the given Claude session id in dir, then switches the client to it. +func (s *Server) ResumeInProject(dir, sessionID string) error { + slug := Slug(dir) + if s.hasSession(slug) { + if _, err := s.run("new-window", "-t", slug, "-c", dir, "-n", "claude", s.launchCmd(dir, sessionID)); err != nil { + return err + } + } else { + if _, err := s.run("new-session", "-d", "-s", slug, "-c", dir, s.launchCmd(dir, sessionID)); err != nil { + return err + } + s.tagProject(slug, dir) + } + _, err := s.run("switch-client", "-t", slug) + return err +} + +// KillWindow terminates the Claude window at target ("session:window"). The +// window-unlinked hook (which auto-detaches on Claude exit) is suppressed around +// the kill so removing a session from the picker doesn't drop the client; it is +// restored immediately after. +func (s *Server) KillWindow(target string) error { + _, _ = s.run("set-hook", "-gu", "window-unlinked") + _, err := s.run("kill-window", "-t", target) + _, _ = s.run("set-hook", "-g", "window-unlinked", "detach-client") + return err +} + +// KillProject kills the tmux session (and thus every running Claude window) for +// dir. It is a no-op when no such session exists. Returns whether one was killed. +func (s *Server) KillProject(dir string) (bool, error) { + slug := Slug(dir) + if !s.hasSession(slug) { + return false, nil + } + _, err := s.run("kill-session", "-t", slug) + return err == nil, err +} + +// KillAll tears down the entire isolated server (every project's sessions). It +// is a no-op when the server is not running. +func (s *Server) KillAll() error { + if _, err := s.run("kill-server"); err != nil { + // kill-server errors when nothing is running; that is fine. + if strings.Contains(err.Error(), "no server running") { + return nil + } + return err + } + return nil +} + +// NewWindowFresh opens a new window running a fresh Claude session in dir. +func (s *Server) NewWindowFresh(dir string) error { + slug := Slug(dir) + if !s.hasSession(slug) { + if _, err := s.run("new-session", "-d", "-s", slug, "-c", dir, s.launchCmd(dir, "")); err != nil { + return err + } + s.tagProject(slug, dir) + _, err := s.run("switch-client", "-t", slug) + return err + } + _, err := s.run("new-window", "-t", slug, "-c", dir, "-n", "claude", s.launchCmd(dir, "")) + return err +} + +// InsideOurServer reports whether the current process is running inside a client +// of this server (i.e. $TMUX points at our socket), which tells the picker +// whether it can drive tmux or must fall back to launching Claude directly. +func (s *Server) InsideOurServer() bool { + t := os.Getenv("TMUX") + if t == "" { + return false + } + // $TMUX is "socket_path,pid,session"; the socket path ends in the name. + sockPath := strings.Split(t, ",")[0] + return filepath.Base(sockPath) == s.Socket +} diff --git a/claude-mux/internal/ui/list.go b/claude-mux/internal/ui/list.go new file mode 100644 index 0000000..57480a8 --- /dev/null +++ b/claude-mux/internal/ui/list.go @@ -0,0 +1,386 @@ +// Package ui renders the floating session picker shown by "claude-mux list". +package ui + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "claude-mux/internal/manager" + "claude-mux/internal/session" + "claude-mux/internal/tmux" +) + +// Action is what the user chose to do when the picker closed. +type Action int + +const ( + // ActionNone means the picker was cancelled. + ActionNone Action = iota + // ActionResume means open the selected session (Result.Entry). + ActionResume + // ActionNew means start a fresh session. + ActionNew +) + +// Result is the outcome of running the picker. +type Result struct { + Action Action + Entry manager.Entry +} + +var ( + titleStyle = lipgloss.NewStyle().Bold(true) + dirStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) + metaStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("246")) + projStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + selBar = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + selRowStyle = lipgloss.NewStyle().Bold(true) + emptyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Italic(true) + + // Per-status colours. + runningStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) // green + questionStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("214")) // amber + openStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // blue + closedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) // dim +) + +// statusDot returns a coloured glyph for a session status. +func statusDot(s session.Status) string { + switch s { + case session.StatusRunning: + return runningStyle.Render("●") + case session.StatusQuestions: + return questionStyle.Render("?") + case session.StatusIdle: + return openStyle.Render("○") + default: // closed + return closedStyle.Render("·") + } +} + +// statusWord returns the human label for a session status. +func statusWord(s session.Status) string { + switch s { + case session.StatusRunning: + return "running" + case session.StatusQuestions: + return "waiting for you" + case session.StatusIdle: + return "open" + default: + return "closed" + } +} + +type model struct { + dir string + srv *tmux.Server + all bool + entries []manager.Entry + cursor int + offset int // index of the first visible row (scrolling) + width int + height int + err error + result Result +} + +// RunPicker shows the interactive picker for dir and returns the chosen action. +// When all is true it starts listing sessions across every project. +func RunPicker(dir string, all bool, srv *tmux.Server) (Result, error) { + m := &model{dir: dir, all: all, srv: srv} + m.reload() + p := tea.NewProgram(m, tea.WithAltScreen()) + final, err := p.Run() + if err != nil { + return Result{}, err + } + return final.(*model).result, nil +} + +func (m *model) reload() { + entries, err := manager.Load(m.dir, m.all, m.srv) + m.err = err + m.entries = entries + if m.cursor >= len(entries) { + m.cursor = len(entries) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } +} + +func (m *model) Init() tea.Cmd { return nil } + +// linesPerEntry is the height of one rendered session (two-line layout). +const linesPerEntry = 2 + +func (m *model) dims() (width, height int) { + width, height = m.width, m.height + if width <= 0 { + width = 80 + } + if height <= 0 { + height = 24 + } + return +} + +// footerHeight is how many lines the (wrapped) help text plus the position line +// occupy at the current width. +func (m *model) footerHeight() int { + width, _ := m.dims() + return lipgloss.Height(helpStyle.Width(width).Render(m.helpText())) + 1 +} + +// pageSize is how many session entries fit, given the two-line layout and the +// space taken by the header (1 line) and footer. +func (m *model) pageSize() int { + _, height := m.dims() + body := height - 1 - m.footerHeight() + if body < linesPerEntry { + return 1 + } + return body / linesPerEntry +} + +// clampScroll keeps the cursor within the visible window and the offset in range. +func (m *model) clampScroll() { + page := m.pageSize() + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+page { + m.offset = m.cursor - page + 1 + } + maxOff := len(m.entries) - page + if maxOff < 0 { + maxOff = 0 + } + if m.offset > maxOff { + m.offset = maxOff + } + if m.offset < 0 { + m.offset = 0 + } +} + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.clampScroll() + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q", "esc": + m.result = Result{Action: ActionNone} + return m, tea.Quit + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + case "down", "j": + if m.cursor < len(m.entries)-1 { + m.cursor++ + } + case "g", "home": + m.cursor = 0 + case "G", "end": + m.cursor = len(m.entries) - 1 + case "r": + m.reload() + case "ctrl+a": + m.all = !m.all + m.cursor = 0 + m.reload() + case "n": + m.result = Result{Action: ActionNew} + return m, tea.Quit + case "x": + // Kill the selected session's running window. Idle sessions have no + // live process, so there is nothing to kill. + if len(m.entries) > 0 { + if e := m.entries[m.cursor]; e.Target != "" { + _ = m.srv.KillWindow(e.Target) + m.reload() + } + } + case "enter": + if len(m.entries) > 0 { + m.result = Result{Action: ActionResume, Entry: m.entries[m.cursor]} + return m, tea.Quit + } + } + m.clampScroll() + } + return m, nil +} + +func (m *model) View() string { + width, _ := m.dims() + var b strings.Builder + + b.WriteString(m.renderHeader(width)) + b.WriteString("\n") + + if m.err != nil { + b.WriteString(emptyStyle.Width(width).Render("error: " + m.err.Error())) + return b.String() + } + if len(m.entries) == 0 { + msg := "No sessions yet for this project." + if m.all { + msg = "No sessions found." + } + b.WriteString(emptyStyle.Width(width).Render(msg)) + b.WriteString("\n") + b.WriteString(m.renderFooter(width)) + return b.String() + } + + page := m.pageSize() + end := m.offset + page + if end > len(m.entries) { + end = len(m.entries) + } + for i := m.offset; i < end; i++ { + b.WriteString(m.renderRow(i, m.entries[i], width)) + b.WriteString("\n") + } + b.WriteString(m.renderFooter(width)) + return b.String() +} + +// renderHeader is the single (truncated) title line. +func (m *model) renderHeader(width int) string { + scope := m.dir + if m.all { + scope = "all projects" + } + const prefix = "Claude sessions · " + avail := width - len([]rune(prefix)) + if avail < 4 { + return titleStyle.Render(truncate("Claude sessions", width)) + } + return titleStyle.Render("Claude sessions") + dimStyle.Render(" · ") + dirStyle.Render(truncate(scope, avail)) +} + +// renderFooter renders the wrapped help text plus a position/summary line. +func (m *model) renderFooter(width int) string { + help := helpStyle.Width(width).Render(m.helpText()) + return help + "\n" + dimStyle.Render(m.positionText()) +} + +// positionText summarises how much of the list is shown. +func (m *model) positionText() string { + total := len(m.entries) + if total == 0 { + return "" + } + page := m.pageSize() + if total > page { + end := m.offset + page + if end > total { + end = total + } + return fmt.Sprintf("%d–%d of %d", m.offset+1, end, total) + } + if total == 1 { + return "1 session" + } + return fmt.Sprintf("%d sessions", total) +} + +// renderRow renders one session across two lines: title on top, status/meta +// below. The two-line layout keeps everything readable in narrow popups. +func (m *model) renderRow(i int, e manager.Entry, width int) string { + selected := i == m.cursor + + // Line 1: selection bar + status dot + title. + bar := " " + if selected { + bar = selBar.Render("▌ ") + } + dot := statusDot(e.Status) + titleW := width - 4 // bar(2) + dot(1) + space(1) + if titleW < 4 { + titleW = 4 + } + title := truncate(e.Title, titleW) + if selected { + title = selRowStyle.Render(title) + } else { + title = titleStyle.Render(title) + } + line1 := bar + dot + " " + title + + // Line 2: indented status/meta, continuing the selection bar. + indent := " " + if selected { + indent = selBar.Render("▌ ") + " " + } + parts := []string{statusWord(e.Status)} + if m.all { + parts = append(parts, filepath.Base(e.ProjectDir)) + } + parts = append(parts, fmt.Sprintf("%d msg", e.Messages), relTime(e.Updated)) + meta := truncate(strings.Join(parts, " · "), width-4) + line2 := indent + metaStyle.Render(meta) + + return line1 + "\n" + line2 +} + +func (m *model) helpText() string { + scope := "ctrl+a all" + if m.all { + scope = "ctrl+a this project" + } + return "↑/↓ move · enter open · n new · x kill · " + scope + " · r refresh · q cancel" +} + +// pad right-pads s to at least n runes. +func pad(s string, n int) string { + if len(s) >= n { + return s + } + return s + strings.Repeat(" ", n-len(s)) +} + +// truncate shortens s to at most n runes, adding an ellipsis when cut. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + if n <= 1 { + return string(r[:n]) + } + return string(r[:n-1]) + "…" +} + +// relTime renders a compact "time ago" string, or a date for older sessions. +func relTime(t time.Time) string { + if t.IsZero() { + return "—" + } + d := time.Since(t) + switch { + case d < time.Minute: + return "just now" + case d < time.Hour: + return fmt.Sprintf("%dm ago", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d.Hours())) + case d < 7*24*time.Hour: + return fmt.Sprintf("%dd ago", int(d.Hours()/24)) + default: + return t.Format("Jan 2") + } +} diff --git a/claude-mux/internal/ui/list_test.go b/claude-mux/internal/ui/list_test.go new file mode 100644 index 0000000..5cd8296 --- /dev/null +++ b/claude-mux/internal/ui/list_test.go @@ -0,0 +1,68 @@ +package ui + +import ( + "testing" + + "claude-mux/internal/manager" +) + +func newTestModel(n, height int) *model { + entries := make([]manager.Entry, n) + m := &model{entries: entries, height: height, width: 80} + return m +} + +func TestPageSizeTwoLineLayout(t *testing.T) { + // height 24, width 80: header(1) + footer(help 1 line + position 1) = 3, + // body 21, two lines per entry -> 10 entries. + m := newTestModel(0, 24) + if got := m.pageSize(); got != 10 { + t.Fatalf("pageSize = %d, want 10", got) + } + m.height = 0 // defaults to 24 + if got := m.pageSize(); got != 10 { + t.Fatalf("pageSize(default) = %d, want 10", got) + } + m.height = 3 // too small for even one two-line entry -> still 1 + if got := m.pageSize(); got != 1 { + t.Fatalf("pageSize(tiny) = %d, want 1", got) + } +} + +func TestClampScrollKeepsCursorVisible(t *testing.T) { + m := newTestModel(20, 10) + page := m.pageSize() + + m.cursor = 19 + m.clampScroll() + if m.cursor < m.offset || m.cursor >= m.offset+page { + t.Fatalf("cursor %d not visible in [%d,%d)", m.cursor, m.offset, m.offset+page) + } + + m.cursor = 0 + m.clampScroll() + if m.offset != 0 { + t.Fatalf("offset = %d, want 0", m.offset) + } +} + +func TestClampScrollOffsetNeverPastEnd(t *testing.T) { + m := newTestModel(20, 10) + page := m.pageSize() + m.offset = 100 + m.cursor = 19 + m.clampScroll() + maxOff := len(m.entries) - page + if m.offset != maxOff { + t.Fatalf("offset = %d, want %d (max)", m.offset, maxOff) + } +} + +func TestClampScrollFewerEntriesThanPage(t *testing.T) { + m := newTestModel(3, 24) // pageSize 10 > 3 + m.cursor = 2 + m.clampScroll() + if m.offset != 0 { + t.Fatalf("offset = %d, want 0 (no scroll needed)", m.offset) + } +} diff --git a/claude-mux/main.go b/claude-mux/main.go new file mode 100644 index 0000000..0bf1c15 --- /dev/null +++ b/claude-mux/main.go @@ -0,0 +1,310 @@ +// Command claude-mux is a tmux-backed session manager for Claude Code. It runs +// an isolated tmux server (its own socket, so it nests cleanly inside another +// tmux) whose only purpose is to host `claude` processes, with two chords: +// +// C-x l float a picker of every Claude session for the project +// C-x n start a fresh Claude session in a new window +// +// See README.md for the full picture. +package main + +import ( + "crypto/rand" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "syscall" + + "claude-mux/internal/manager" + "claude-mux/internal/state" + "claude-mux/internal/tmux" + "claude-mux/internal/ui" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "claude-mux:", err) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) > 0 { + switch args[0] { + case "list": + return cmdList(args[1:]) + case "new": + return cmdNew(args[1:]) + case "run": + return cmdRun(args[1:]) + case "kill": + return cmdKill(args[1:]) + case "hook": + return cmdHook(args[1:]) + case "-h", "--help", "help": + usage() + return nil + } + } + return cmdAttach() +} + +func usage() { + fmt.Print(`claude-mux - a tmux-backed session manager for Claude Code + +Usage: + claude-mux Start or attach the Claude session for the current directory + claude-mux list Show the interactive session picker (used by the C-x l chord) + claude-mux new Start a fresh Claude session in the current directory + claude-mux kill Kill the running sessions for the current directory + claude-mux kill --all Kill every running session across all projects + +Inside a session: + C-x l list every Claude session for this project + C-x n new Claude session in a background window + C-x d detach (everything keeps running) + +In the picker: enter open · n new · x kill selected · ctrl+a all · q cancel + +Environment: + CLAUDE_MUX_SOCKET tmux socket name (default "claude-mux") + CLAUDE_CONFIG_DIR Claude's config dir (session transcripts live here) +`) +} + +// cmdAttach starts/attaches the isolated tmux session for the current directory. +func cmdAttach() error { + dir, err := os.Getwd() + if err != nil { + return err + } + srv := tmux.New() + if srv.InsideOurServer() { + return fmt.Errorf("already inside a claude-mux session (use C-x n for a new session, C-x d to detach)") + } + slug, err := srv.EnsureSession(dir) + if err != nil { + return err + } + return srv.Attach(slug) +} + +// cmdRun is the launcher every Claude window goes through. It settles on a +// session id (a fresh uuid, or the one being resumed), records it on the tmux +// window so the picker can find this session later, then execs Claude. +func cmdRun(args []string) error { + fs := flag.NewFlagSet("run", flag.ContinueOnError) + dirFlag := fs.String("dir", "", "project directory (defaults to cwd)") + resumeFlag := fs.String("resume", "", "resume this session id instead of starting fresh") + if err := fs.Parse(args); err != nil { + return err + } + dir := resolveDir(*dirFlag) + + id := *resumeFlag + if id == "" { + id = genUUID() + } + srv := tmux.New() + if pane := os.Getenv("TMUX_PANE"); pane != "" && srv.InsideOurServer() { + _ = srv.TagWindow(pane, id) + } + + if *resumeFlag != "" { + return execIn(dir, "claude", "--resume", id) + } + return execIn(dir, "claude", "--session-id", id) +} + +// cmdKill terminates running sessions: those for the current project, or every +// project with --all. Transcripts (history) are left untouched. +func cmdKill(args []string) error { + fs := flag.NewFlagSet("kill", flag.ContinueOnError) + dirFlag := fs.String("dir", "", "project directory (defaults to cwd)") + all := fs.Bool("all", false, "kill running sessions across all projects") + if err := fs.Parse(args); err != nil { + return err + } + srv := tmux.New() + if *all { + if err := srv.KillAll(); err != nil { + return err + } + fmt.Println("killed all claude-mux sessions") + return nil + } + dir := resolveDir(*dirFlag) + killed, err := srv.KillProject(dir) + if err != nil { + return err + } + if killed { + fmt.Printf("killed running sessions for %s\n", dir) + } else { + fmt.Printf("no running sessions for %s\n", dir) + } + return nil +} + +// cmdHook is invoked by Claude Code hooks to record a session's live status. It +// reads the hook JSON payload from stdin (for the session id) and writes the +// status where the picker can read it. It prints nothing so it is safe to wire +// into stdout-sensitive hooks like UserPromptSubmit. +func cmdHook(args []string) error { + fs := flag.NewFlagSet("hook", flag.ContinueOnError) + status := fs.String("status", "", "status to record: running|questions|idle|closed") + if err := fs.Parse(args); err != nil { + return err + } + var payload struct { + SessionID string `json:"session_id"` + } + if data, err := io.ReadAll(os.Stdin); err == nil && len(data) > 0 { + _ = json.Unmarshal(data, &payload) + } + if payload.SessionID == "" { + return nil // nothing we can key on; do not fail the hook + } + if *status == "closed" { + return state.Clear(payload.SessionID) + } + return state.Set(payload.SessionID, *status) +} + +// cmdNew starts a fresh Claude session: a new window when inside the server, +// otherwise plain `claude` for standalone use. +func cmdNew(args []string) error { + fs := flag.NewFlagSet("new", flag.ContinueOnError) + dirFlag := fs.String("dir", "", "project directory (defaults to cwd)") + if err := fs.Parse(args); err != nil { + return err + } + dir := resolveDir(*dirFlag) + srv := tmux.New() + if srv.InsideOurServer() { + return srv.NewWindowFresh(dir) + } + return execClaude(dir) +} + +// cmdList runs the picker and carries out the chosen action. +func cmdList(args []string) error { + fs := flag.NewFlagSet("list", flag.ContinueOnError) + dirFlag := fs.String("dir", "", "project directory (defaults to cwd)") + sockFlag := fs.String("socket", "", "tmux socket name") + all := fs.Bool("all", false, "list sessions across all projects, not just this one") + dump := fs.Bool("dump", false, "print sessions as plain text instead of the interactive picker") + if err := fs.Parse(args); err != nil { + return err + } + if *sockFlag != "" { + os.Setenv("CLAUDE_MUX_SOCKET", *sockFlag) + } + dir := resolveDir(*dirFlag) + srv := tmux.New() + + if *dump { + return dumpList(dir, *all, srv) + } + + res, err := ui.RunPicker(dir, *all, srv) + if err != nil { + return err + } + + switch res.Action { + case ui.ActionNew: + if srv.InsideOurServer() { + return srv.NewWindowFresh(dir) // "new" always lands in the current project + } + return execClaude(dir) + case ui.ActionResume: + e := res.Entry + pdir := e.ProjectDir + if pdir == "" { + pdir = dir + } + if srv.InsideOurServer() { + if e.Target != "" { // already open in a window: just jump to it + return srv.Focus(e.Target) + } + return srv.ResumeInProject(pdir, e.ID) + } + return execClaudeResume(pdir, e.ID) + } + return nil // cancelled +} + +// dumpList prints the enriched session listing as plain text (non-interactive). +func dumpList(dir string, all bool, srv *tmux.Server) error { + entries, err := manager.Load(dir, all, srv) + if err != nil { + return err + } + fmt.Printf("%d session(s) for %s\n", len(entries), dir) + for _, e := range entries { + target := e.Target + if target == "" { + target = "-" + } + fmt.Printf(" [%-7s] %-40.40s %2d msg %s pane=%s %s\n", + e.Status, e.Title, e.Messages, e.Updated.Format("2006-01-02 15:04"), target, e.ID) + } + return nil +} + +// resolveDir returns an absolute project directory. It prefers the given value, +// then $CLAUDE_MUX_PROJECT_DIR (set on the tmux session, so the popup resolves +// the right project), then the current working directory. +func resolveDir(dir string) string { + if dir == "" { + dir = os.Getenv("CLAUDE_MUX_PROJECT_DIR") + } + if dir == "" { + if wd, err := os.Getwd(); err == nil { + return wd + } + return "." + } + if abs, err := filepath.Abs(dir); err == nil { + return abs + } + return dir +} + +// execClaude replaces the process with a fresh `claude` running in dir. +func execClaude(dir string) error { + return execIn(dir, "claude") +} + +// execClaudeResume replaces the process with `claude --resume id` in dir. +func execClaudeResume(dir, id string) error { + return execIn(dir, "claude", "--resume", id) +} + +// genUUID returns a random RFC 4122 v4 UUID for a new Claude session id. +func genUUID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand should not fail; fall back to a time-free but unique-ish id. + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +func execIn(dir string, name string, args ...string) error { + bin, err := exec.LookPath(name) + if err != nil { + return err + } + if err := os.Chdir(dir); err != nil { + return err + } + return syscall.Exec(bin, append([]string{name}, args...), os.Environ()) +} diff --git a/modules/cli/home.nix b/modules/cli/home.nix index ccd9152..172a2d1 100644 --- a/modules/cli/home.nix +++ b/modules/cli/home.nix @@ -11,6 +11,8 @@ ./tools/tmux.nix ]; + home.packages = [pkgs.claude-mux]; + programs.claude-code = { enable = true; configDir = "${config.xdg.configHome}/claude"; @@ -78,9 +80,23 @@ ); in "${claudeNotify}"; } + { + type = "command"; + command = "${lib.getExe pkgs.claude-mux} hook --status idle"; + } ]; } ]; + + hooks.UserPromptSubmit = [ + {hooks = [{type = "command"; command = "${lib.getExe pkgs.claude-mux} hook --status running";}];} + ]; + hooks.Notification = [ + {hooks = [{type = "command"; command = "${lib.getExe pkgs.claude-mux} hook --status questions";}];} + ]; + hooks.SessionEnd = [ + {hooks = [{type = "command"; command = "${lib.getExe pkgs.claude-mux} hook --status closed";}];} + ]; }} "${config.xdg.configHome}/claude/settings.json" ''; @@ -109,7 +125,7 @@ "ctrl+shift+s" = "chat:stash"; "enter" = "chat:newline"; "ctrl+d" = "chat:cancel"; - "ctrl+u" = "chat:clearInput"; + "ctrl+u" = "chat:stash"; "ctrl+z" = "chat:undo"; "ctrl+y" = "chat:undo"; "ctrl+shift+z" = "chat:redo"; diff --git a/nvim/lua/plugins/opencode.lua b/nvim/lua/plugins/opencode.lua index 215b9ac..bc519b9 100644 --- a/nvim/lua/plugins/opencode.lua +++ b/nvim/lua/plugins/opencode.lua @@ -10,7 +10,6 @@ local snacks_terminal_opts = { }, } -local claude_cmd = 'claude' ---@type snacks.terminal.Opts local claude_terminal_opts = { win = { @@ -54,7 +53,7 @@ return { "l", function() if get_mode() == "claude" then - require('snacks.terminal').toggle(claude_cmd, claude_terminal_opts) + require("snacks.terminal").toggle("claude-mux", claude_terminal_opts) else require("opencode").toggle() end diff --git a/overlays/default.nix b/overlays/default.nix index f37e43e..9b26594 100644 --- a/overlays/default.nix +++ b/overlays/default.nix @@ -41,4 +41,20 @@ in { vesktop = enableWayland super.vesktop ["vesktop"]; pear-desktop = enableWayland super.pear-desktop ["pear-desktop"]; vscode = enableWayland super.vscode ["code"]; + + claude-mux = super.buildGoModule { + pname = "claude-mux"; + version = "0.1.0"; + src = ../claude-mux; + vendorHash = "sha256-uwBJAqN4sIepiiJf9lCDumLqfKJEowQO2tOiSWD3Fig="; + nativeBuildInputs = [super.makeWrapper]; + postInstall = '' + wrapProgram $out/bin/claude-mux \ + --prefix PATH : ${super.lib.makeBinPath [super.tmux]} + ''; + meta = { + description = "tmux-backed session manager for Claude Code"; + mainProgram = "claude-mux"; + }; + }; }