mirror of
https://github.com/zoriya/flake.git
synced 2026-08-15 18:43:42 +00:00
Add usage pane in claude-mux
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{pkgs ? import <nixpkgs> {}}:
|
||||
pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
go
|
||||
gopls
|
||||
gotools
|
||||
tmux
|
||||
];
|
||||
}
|
||||
@@ -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";}];}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user