Add usage pane in claude-mux

This commit is contained in:
2026-07-27 12:09:02 +02:00
parent f0e9e1fca9
commit 44ef7dbd6d
7 changed files with 205 additions and 3 deletions
+10
View File
@@ -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/
+16 -1
View File
@@ -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
+1 -1
View File
@@ -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
+69 -1
View File
@@ -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
+97
View File
@@ -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)
+9
View File
@@ -0,0 +1,9 @@
{pkgs ? import <nixpkgs> {}}:
pkgs.mkShell {
packages = with pkgs; [
go
gopls
gotools
tmux
];
}
+3
View File
@@ -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";}];}
];