diff --git a/claude-mux/.gitignore b/claude-mux/.gitignore new file mode 100644 index 0000000..b07f35c --- /dev/null +++ b/claude-mux/.gitignore @@ -0,0 +1,10 @@ +# Local build output (the binary shares the module name). +/claude-mux + +# Go build/test artifacts. +*.test +*.out +/dist/ + +# Editor / direnv cruft. +.direnv/ diff --git a/claude-mux/README.md b/claude-mux/README.md index f162296..b2032d0 100644 --- a/claude-mux/README.md +++ b/claude-mux/README.md @@ -24,6 +24,7 @@ a session there are exactly two chords plus a detach, under the `C-x` prefix: | `C-x l` | Float a picker of **every** Claude session for this project | | `C-x r` | Float the remote-control (`claude rc`) toggle for this project | | `C-x n` | Start a fresh Claude session in a new window (current keeps going) | +| `C-x u` | Float the current Claude usage (limits) for this project | | `C-x d` | Detach — everything keeps running in the background | | `C-x C-x` | Send a literal `C-x` through to Claude | @@ -82,6 +83,15 @@ 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. +### Usage (`C-x u`) + +`C-x u` floats a pane showing your current Claude usage — session and weekly +limits with their progress bars, plus what is driving them. It renders Claude's +interactive `/usage` panel (the pretty, coloured one) off-screen in a throwaway +tmux pane and snapshots it, so you get the real view without a full session +lingering. Press `q` (or `esc` / `ctrl+c`) to close it. If the interactive +render is unavailable it falls back to the plain-text `claude -p /usage` report. + ### Remote control (`C-x r`) [`claude rc`](https://claude.com/claude-code) (remote-control) runs a persistent @@ -119,7 +129,11 @@ are always available without having to open each project by hand. - **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. + the picker tell a live session apart and jump straight to its window. Claude's + live session id can drift from the one baked in at launch — `/clear`, in-app + `/resume` and context compaction each mint a fresh id in the same window — so + the status hook re-tags its own window with the id it reports, keeping the tag + pointed at the session that is actually running there. - Each project directory gets its own tmux session on the shared socket, named after the directory (basename + a short path hash). @@ -138,6 +152,7 @@ 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 rc Remote-control toggle popup (used by the C-x r chord) +claude-mux usage Show the current Claude usage/limits (used by the C-x u 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 diff --git a/claude-mux/go.mod b/claude-mux/go.mod index 2c6eb84..4d55cc2 100644 --- a/claude-mux/go.mod +++ b/claude-mux/go.mod @@ -5,6 +5,7 @@ go 1.26.5 require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/term v0.2.1 ) require ( @@ -12,7 +13,6 @@ require ( 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 diff --git a/claude-mux/internal/tmux/tmux.go b/claude-mux/internal/tmux/tmux.go index d7e9230..c36c022 100644 --- a/claude-mux/internal/tmux/tmux.go +++ b/claude-mux/internal/tmux/tmux.go @@ -10,8 +10,10 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" + "time" "claude-mux/internal/claude" "claude-mux/internal/paths" @@ -151,9 +153,13 @@ bind -T prefix r display-popup -d "#{@claude_project_dir}" -w 60%% -h 40%% -E "' # does not expand formats there, so an embedded --dir '#{@claude_project_dir}' # would reach run as a literal path, fail to chdir, and drop the whole session. bind -T prefix n new-window -c "#{@claude_project_dir}" -n claude "exec '%s' run" +# C-x u : float the current Claude usage (limits). The command stays up until +# the user presses q (claude-mux usage blocks on a keypress), so -E can close +# the popup on exit without it flashing away the instant claude prints. +bind -T prefix u display-popup -d "#{@claude_project_dir}" -w 80%% -h 75%% -E "'%s' usage" # C-x d : detach and leave everything running in the background. bind -T prefix d detach-client -`, binPath, socket, binPath, socket, binPath) +`, binPath, socket, binPath, socket, binPath, binPath) path := configPath() if err := os.WriteFile(path, []byte(conf), 0o644); err != nil { @@ -251,6 +257,16 @@ func (s *Server) TagWindow(paneTarget, sessionID string) error { return err } +// WindowSessionID returns the Claude session id currently tagged on the window +// containing paneTarget, or "" when it is untagged or cannot be resolved. +func (s *Server) WindowSessionID(paneTarget string) string { + out, err := s.run("show-options", "-wqv", "-t", paneTarget, sessionIDOption) + if err != nil { + return "" + } + return strings.TrimSpace(out) +} + // hasSession reports whether a session named slug exists. func (s *Server) hasSession(slug string) bool { _, err := s.run("has-session", "-t", slug) @@ -338,6 +354,58 @@ func (s *Server) NewWindowFresh(dir string) error { return err } +// usageSocket is the throwaway tmux socket used to render `claude /usage` +// headlessly. It is deliberately a *different* socket from the claude-mux +// server so the render never creates a window, session or picker entry there. +const usageSocket = "claude-mux-usage" + +// CaptureUsage renders Claude's interactive `/usage` panel (the one with the +// coloured progress bars) in a throwaway, off-screen tmux pane sized cols×rows, +// then returns the captured screen with its ANSI colours preserved so a popup +// can print it verbatim. `/usage` draws as a full-screen overlay, so the whole +// pane is the panel — no composer to strip. +// +// The render runs on its own tmux socket (see usageSocket) which is torn down +// before returning, so it never touches the claude-mux server. It polls until +// the bars have painted (or times out), because Claude takes a moment to boot. +func CaptureUsage(dir string, cols, rows int) (string, error) { + run := func(args ...string) (string, error) { + out, err := exec.Command("tmux", append([]string{"-L", usageSocket}, args...)...).Output() + return string(out), err + } + // Start clean in case a previous render was left behind, and always tear the + // scratch server down on the way out. + _, _ = run("kill-server") + defer func() { _, _ = run("kill-server") }() + + // Pass Claude's config dir through explicitly: a fresh tmux server does not + // inherit arbitrary env vars, and the render must read the same transcripts. + newArgs := []string{"new-session", "-d", "-s", "u", + "-x", strconv.Itoa(cols), "-y", strconv.Itoa(rows), "-c", dir} + if cfg := os.Getenv("CLAUDE_CONFIG_DIR"); cfg != "" { + newArgs = append(newArgs, "-e", "CLAUDE_CONFIG_DIR="+cfg) + } + newArgs = append(newArgs, `claude "/usage"`) + if _, err := run(newArgs...); err != nil { + return "", err + } + + var last string + for i := 0; i < 60; i++ { + out, err := run("capture-pane", "-p", "-e", "-t", "u") + if err == nil { + last = out + // The panel has painted once a progress bar (█) and a "% used" + // label are both on screen. + if strings.Contains(out, "% used") && strings.Contains(out, "█") { + return strings.TrimRight(out, "\n"), nil + } + } + time.Sleep(250 * time.Millisecond) + } + return strings.TrimRight(last, "\n"), fmt.Errorf("timed out waiting for usage to render") +} + // rcSuffix is appended to a project's slug to name its dedicated tmux session // hosting the Claude remote-control (`claude rc`) server. Keeping rc in its own // session (rather than a window in the project session) means an rc-enabled diff --git a/claude-mux/main.go b/claude-mux/main.go index 15ea8f9..03d32f8 100644 --- a/claude-mux/main.go +++ b/claude-mux/main.go @@ -5,11 +5,13 @@ // C-x l float a picker of every Claude session for the project // C-x r toggle a persistent `claude rc` (remote-control) server for the project // C-x n start a fresh Claude session in a new window +// C-x u float the current Claude usage (limits) for the project // // See README.md for the full picture. package main import ( + "bufio" "crypto/rand" "encoding/json" "flag" @@ -21,6 +23,8 @@ import ( "strings" "syscall" + "github.com/charmbracelet/x/term" + "claude-mux/internal/manager" "claude-mux/internal/rc" "claude-mux/internal/state" @@ -46,6 +50,8 @@ func run(args []string) error { return cmdNew(args[1:]) case "run": return cmdRun(args[1:]) + case "usage": + return cmdUsage(args[1:]) case "kill": return cmdKill(args[1:]) case "hook": @@ -72,6 +78,7 @@ Inside a session: C-x l list every Claude session for this project C-x r toggle a persistent remote-control (claude rc) server for this project C-x n new Claude session in a background window + C-x u show the current Claude usage (limits) in a floating pane C-x d detach (everything keeps running) In the picker: enter open · n new · x kill selected · ctrl+a all · q cancel @@ -198,6 +205,26 @@ func cmdHook(args []string) error { if st == "closed" { return state.Clear(payload.SessionID) } + + // Keep the hosting tmux window's session-id tag in sync with the id Claude is + // actually reporting. claude-mux tags a window with the id it launched (see + // cmdRun), but Claude's live session id diverges from that whenever a new id + // is minted in the same window — /clear, in-app /resume, context compaction. + // Left alone, the window would point at a dead id (the picker shows it "open" + // and its real, running state — keyed to the new id — has no window, so it + // reads as closed). Re-tagging on every hook self-heals that: the window now + // carries the live id, and the id it used to host is no longer live here, so + // its stale state is cleared rather than left to accumulate. + if pane := os.Getenv("TMUX_PANE"); pane != "" { + srv := tmux.New() + if srv.InsideOurServer() { + if old := srv.WindowSessionID(pane); old != "" && old != payload.SessionID { + _ = state.Clear(old) + } + _ = srv.TagWindow(pane, payload.SessionID) + } + } + return state.Set(payload.SessionID, st) } @@ -224,6 +251,76 @@ func cmdNew(args []string) error { return execClaude(dir) } +// cmdUsage renders the current Claude usage into the floating pane opened by +// the C-x u chord. It snapshots Claude's interactive `/usage` panel — the one +// with the coloured progress bars — by rendering it in a throwaway off-screen +// tmux pane sized to this popup and printing the captured screen verbatim. It +// then blocks until the user presses q (or Esc / Ctrl-C) so the popup stays up +// to be read instead of closing the instant the command exits. +// +// If the interactive render is unavailable (no tmux, or it times out) it falls +// back to the plain-text `claude -p /usage` report so something still shows. +func cmdUsage(args []string) error { + fs := flag.NewFlagSet("usage", flag.ContinueOnError) + dirFlag := fs.String("dir", "", "project directory (defaults to cwd)") + if err := fs.Parse(args); err != nil { + return err + } + dir := resolveDir(*dirFlag) + + // Reserve the bottom row for the "press q" hint so it never scrolls the top + // of the panel out of view. + cols, rows := 80, 24 + if w, h, err := term.GetSize(os.Stdout.Fd()); err == nil && w > 0 && h > 0 { + cols, rows = w, h + } + renderRows := rows - 1 + if renderRows < 8 { + renderRows = rows + } + + if screen, err := tmux.CaptureUsage(dir, cols, renderRows); err == nil && strings.Contains(screen, "% used") { + os.Stdout.WriteString(screen) + } else { + cmd := exec.Command("claude", "-p", "/usage") + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if runErr := cmd.Run(); runErr != nil { + fmt.Fprintln(os.Stderr, "\nfailed to fetch usage:", runErr) + } + } + + fmt.Print("\n\x1b[2m─── press q to close ───\x1b[0m") + waitForQuit() + return nil +} + +// waitForQuit blocks until the user presses q/Q, Esc or Ctrl-C. It puts the +// terminal in raw mode so a single keypress is enough; if raw mode is +// unavailable (stdin is not a tty) it falls back to reading a line. +func waitForQuit() { + fd := os.Stdin.Fd() + old, err := term.MakeRaw(fd) + if err != nil { + bufio.NewReader(os.Stdin).ReadString('\n') + return + } + defer term.Restore(fd, old) + + buf := make([]byte, 1) + for { + n, err := os.Stdin.Read(buf) + if err != nil || n == 0 { + return + } + switch buf[0] { + case 'q', 'Q', 3, 27: // q, Q, Ctrl-C, Esc + return + } + } +} + // cmdList runs the picker and carries out the chosen action. func cmdList(args []string) error { fs := flag.NewFlagSet("list", flag.ContinueOnError) diff --git a/claude-mux/shell.nix b/claude-mux/shell.nix new file mode 100644 index 0000000..ac6946c --- /dev/null +++ b/claude-mux/shell.nix @@ -0,0 +1,9 @@ +{pkgs ? import {}}: +pkgs.mkShell { + packages = with pkgs; [ + go + gopls + gotools + tmux + ]; +} diff --git a/modules/cli/home.nix b/modules/cli/home.nix index 172a2d1..8d58447 100644 --- a/modules/cli/home.nix +++ b/modules/cli/home.nix @@ -94,6 +94,9 @@ hooks.Notification = [ {hooks = [{type = "command"; command = "${lib.getExe pkgs.claude-mux} hook --status questions";}];} ]; + hooks.SessionStart = [ + {hooks = [{type = "command"; command = "${lib.getExe pkgs.claude-mux} hook --status idle";}];} + ]; hooks.SessionEnd = [ {hooks = [{type = "command"; command = "${lib.getExe pkgs.claude-mux} hook --status closed";}];} ];