From 82789f47ed863c7354ae2a39d591b15fb5a6956e Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Tue, 28 Jul 2026 19:09:55 +0200 Subject: [PATCH] Make claude-mux faster --- claude-mux/README.md | 1 + claude-mux/internal/session/cache.go | 96 ++++++++++++++++++++++++++ claude-mux/internal/session/session.go | 60 +++++++++++++--- claude-mux/internal/tmux/tmux.go | 20 ++++++ claude-mux/internal/ui/list.go | 74 ++++++++++++++------ claude-mux/main.go | 28 ++++++++ modules/cli/home.nix | 5 ++ 7 files changed, 256 insertions(+), 28 deletions(-) create mode 100644 claude-mux/internal/session/cache.go diff --git a/claude-mux/README.md b/claude-mux/README.md index b2032d0..9642164 100644 --- a/claude-mux/README.md +++ b/claude-mux/README.md @@ -156,6 +156,7 @@ claude-mux usage Show the current Claude usage/limits (used by the C-x u ch 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 reload Repoint a running server's chords at this binary (live) claude-mux run Internal launcher used by the tmux windows ``` diff --git a/claude-mux/internal/session/cache.go b/claude-mux/internal/session/cache.go new file mode 100644 index 0000000..7252026 --- /dev/null +++ b/claude-mux/internal/session/cache.go @@ -0,0 +1,96 @@ +package session + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "claude-mux/internal/paths" +) + +// The transcript listing is the picker's hot path: every "claude-mux list" is a +// fresh process (the C-x l popup) that would otherwise re-parse every transcript +// from scratch, and the picker reloads once a second while open. Transcripts are +// append-only, so a file whose size and modtime are unchanged parses to exactly +// what we saw last time. cache memoises parse() output keyed by (size, modtime) +// and persists it between runs, so a warm open only touches the handful of +// transcripts that actually changed. + +// cachePath is where parsed transcript metadata is memoised between runs. +func cachePath() string { + return filepath.Join(paths.CacheDir(), "sessions.json") +} + +// cacheEntry is one memoised transcript: the parse result plus the file identity +// it was parsed from, so a changed file is detected and re-parsed. +type cacheEntry struct { + Size int64 `json:"size"` + ModTime int64 `json:"mod"` // unix nanoseconds + Session Session `json:"session"` +} + +// cache is a process-local view of the on-disk memo, safe for the concurrent +// parsing in listDir. +type cache struct { + mu sync.Mutex + entries map[string]cacheEntry // keyed by absolute transcript path + dirty bool +} + +// loadCache reads the persisted memo. A missing or unreadable cache is not an +// error; it just means every transcript is a miss this run. +func loadCache() *cache { + c := &cache{entries: map[string]cacheEntry{}} + if data, err := os.ReadFile(cachePath()); err == nil { + _ = json.Unmarshal(data, &c.entries) + } + return c +} + +// get returns the memoised session for path when the file is unchanged since it +// was cached. +func (c *cache) get(path string, info os.FileInfo) (Session, bool) { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[path] + if !ok || e.Size != info.Size() || e.ModTime != info.ModTime().UnixNano() { + return Session{}, false + } + return e.Session, true +} + +// put records a freshly parsed session under its current file identity. +func (c *cache) put(path string, info os.FileInfo, s Session) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[path] = cacheEntry{Size: info.Size(), ModTime: info.ModTime().UnixNano(), Session: s} + c.dirty = true +} + +// save writes the memo back atomically (temp file + rename). It is a no-op when +// nothing changed. Concurrent writers (two popups at once) use a pid-unique temp +// name and race only on the final rename, where last-writer-wins is harmless: +// the loser's fresh entries are simply re-parsed next time. +func (c *cache) save() { + c.mu.Lock() + defer c.mu.Unlock() + if !c.dirty { + return + } + data, err := json.Marshal(c.entries) + if err != nil { + return + } + if err := os.MkdirAll(paths.CacheDir(), 0o755); err != nil { + return + } + tmp := fmt.Sprintf("%s.tmp.%d", cachePath(), os.Getpid()) + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return + } + if err := os.Rename(tmp, cachePath()); err != nil { + _ = os.Remove(tmp) + } +} diff --git a/claude-mux/internal/session/session.go b/claude-mux/internal/session/session.go index 594d85f..110f734 100644 --- a/claude-mux/internal/session/session.go +++ b/claude-mux/internal/session/session.go @@ -8,8 +8,10 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" + "sync" "time" "claude-mux/internal/paths" @@ -85,10 +87,12 @@ type line struct { // 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)) + c := loadCache() + sessions, err := listDir(paths.SessionDir(projectDir), c) if err != nil { return nil, err } + c.save() sortByRecent(sessions) return sessions, nil } @@ -104,23 +108,27 @@ func ListAll() ([]Session, error) { } return nil, err } + c := loadCache() var all []Session for _, p := range projects { if !p.IsDir() { continue } - sessions, err := listDir(filepath.Join(paths.ProjectsDir(), p.Name())) + sessions, err := listDir(filepath.Join(paths.ProjectsDir(), p.Name()), c) if err != nil { continue } all = append(all, sessions...) } + c.save() sortByRecent(all) return all, nil } -// listDir parses every *.jsonl transcript directly inside dir. -func listDir(dir string) ([]Session, error) { +// listDir parses every *.jsonl transcript directly inside dir. Unchanged +// transcripts are served from the cache; the rest are parsed concurrently, so +// the cost scales with what actually changed rather than the whole history. +func listDir(dir string, c *cache) ([]Session, error) { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { @@ -128,16 +136,52 @@ func listDir(dir string) ([]Session, error) { } return nil, err } - var sessions []Session + + var ( + files []string + infos []os.FileInfo + ) for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") { continue } - s, err := parse(filepath.Join(dir, e.Name())) + info, err := e.Info() if err != nil { - continue // skip unreadable/corrupt transcripts rather than fail the whole list + continue + } + files = append(files, filepath.Join(dir, e.Name())) + infos = append(infos, info) + } + + results := make([]Session, len(files)) + ok := make([]bool, len(files)) + sem := make(chan struct{}, runtime.NumCPU()) + var wg sync.WaitGroup + for i := range files { + if s, hit := c.get(files[i], infos[i]); hit { + results[i], ok[i] = s, true + continue + } + wg.Add(1) + sem <- struct{}{} + go func(i int) { + defer wg.Done() + defer func() { <-sem }() + s, err := parse(files[i]) + if err != nil { + return // skip unreadable/corrupt transcripts rather than fail the whole list + } + c.put(files[i], infos[i], s) + results[i], ok[i] = s, true + }(i) + } + wg.Wait() + + sessions := make([]Session, 0, len(files)) + for i := range results { + if ok[i] { + sessions = append(sessions, results[i]) } - sessions = append(sessions, s) } return sessions, nil } diff --git a/claude-mux/internal/tmux/tmux.go b/claude-mux/internal/tmux/tmux.go index c36c022..4895ebf 100644 --- a/claude-mux/internal/tmux/tmux.go +++ b/claude-mux/internal/tmux/tmux.go @@ -194,6 +194,26 @@ func (s *Server) EnsureSession(dir string) (string, error) { return slug, nil } +// Reload regenerates the isolated config from this binary and re-sources it into +// the running server, so its bindings (the C-x chords) point at the current +// claude-mux binary. Running Claude windows are left untouched — they are plain +// `claude` processes, not claude-mux. It is a no-op (reloaded=false) when the +// server is not running. This is what lets a Nix rebuild repoint a live server's +// chords at the new store path without a detach/reattach. +func (s *Server) Reload() (bool, error) { + if !s.IsRunning() { + return false, nil + } + conf, err := writeConfig(s.BinPath, s.Socket) + if err != nil { + return false, err + } + if _, err := s.run("source-file", conf); err != nil { + return false, err + } + return true, 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). diff --git a/claude-mux/internal/ui/list.go b/claude-mux/internal/ui/list.go index a5c35bf..a75aa79 100644 --- a/claude-mux/internal/ui/list.go +++ b/claude-mux/internal/ui/list.go @@ -94,6 +94,8 @@ type model struct { height int err error result Result + loading bool // a background load is in flight + loaded bool // at least one load has completed } // RunPicker shows the interactive picker for dir and returns the chosen action. @@ -103,7 +105,6 @@ func RunPicker(dir string, all bool, srv *tmux.Server) (Result, error) { if srv.InsideOurServer() { m.current = srv.CurrentSessionID() } - m.reload() p := tea.NewProgram(m, tea.WithAltScreen()) final, err := p.Run() if err != nil { @@ -112,32 +113,57 @@ func RunPicker(dir string, all bool, srv *tmux.Server) (Result, error) { return final.(*model).result, nil } -func (m *model) reload() { - // Remember which session is highlighted so the cursor tracks it even when the - // list is reordered by an auto-reload (attention sorting moves rows around). +// loadedMsg carries the result of a background session load. +type loadedMsg struct { + entries []manager.Entry + err error +} + +// startLoad kicks off a background load unless one is already running, so the +// picker paints and stays responsive while sessions are read off the main loop. +// Returns nil when a load is already in flight (the tick just skips this round). +func (m *model) startLoad() tea.Cmd { + if m.loading { + return nil + } + m.loading = true + dir, all, srv := m.dir, m.all, m.srv + return func() tea.Msg { + entries, err := manager.Load(dir, all, srv) + return loadedMsg{entries: entries, err: err} + } +} + +// applyLoad folds a completed load into the model, keeping the cursor on the +// same session even when attention sorting reorders the list. +func (m *model) applyLoad(msg loadedMsg) { + m.loading = false + m.loaded = true + m.err = msg.err + if msg.err != nil { + return + } + var selectedID string if m.cursor >= 0 && m.cursor < len(m.entries) { selectedID = m.entries[m.cursor].ID } - - entries, err := manager.Load(m.dir, m.all, m.srv) - m.err = err - m.entries = entries - + m.entries = msg.entries if selectedID != "" { - for i, e := range entries { + for i, e := range m.entries { if e.ID == selectedID { m.cursor = i break } } } - if m.cursor >= len(entries) { - m.cursor = len(entries) - 1 + if m.cursor >= len(m.entries) { + m.cursor = len(m.entries) - 1 } if m.cursor < 0 { m.cursor = 0 } + m.clampScroll() } // reloadInterval is how often an open picker re-reads sessions so its statuses, @@ -151,7 +177,7 @@ func tick() tea.Cmd { return tea.Tick(reloadInterval, func(time.Time) tea.Msg { return tickMsg{} }) } -func (m *model) Init() tea.Cmd { return tick() } +func (m *model) Init() tea.Cmd { return tea.Batch(m.startLoad(), tick()) } // linesPerEntry is the height of one rendered session (two-line layout). const linesPerEntry = 2 @@ -208,14 +234,16 @@ func (m *model) clampScroll() { func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { + case loadedMsg: + m.applyLoad(msg) + return m, nil case tickMsg: - m.reload() - m.clampScroll() - return m, tick() + return m, tea.Batch(m.startLoad(), tick()) case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.clampScroll() case tea.KeyMsg: + var cmd tea.Cmd switch msg.String() { case "ctrl+c", "q", "esc": m.result = Result{Action: ActionNone} @@ -235,7 +263,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+a": m.all = !m.all m.cursor = 0 - m.reload() + cmd = m.startLoad() case "n": m.result = Result{Action: ActionNew} return m, tea.Quit @@ -245,7 +273,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if len(m.entries) > 0 { if e := m.entries[m.cursor]; e.Target != "" { _ = m.srv.KillWindow(e.Target) - m.reload() + cmd = m.startLoad() } } case "enter": @@ -255,6 +283,7 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } m.clampScroll() + return m, cmd } return m, nil } @@ -271,9 +300,14 @@ func (m *model) View() string { return b.String() } if len(m.entries) == 0 { - msg := "No sessions yet for this project." - if m.all { + var msg string + switch { + case !m.loaded: + msg = "Loading sessions…" + case m.all: msg = "No sessions found." + default: + msg = "No sessions yet for this project." } b.WriteString(emptyStyle.Width(width).Render(msg)) b.WriteString("\n") diff --git a/claude-mux/main.go b/claude-mux/main.go index 03d32f8..5b40199 100644 --- a/claude-mux/main.go +++ b/claude-mux/main.go @@ -54,6 +54,8 @@ func run(args []string) error { return cmdUsage(args[1:]) case "kill": return cmdKill(args[1:]) + case "reload": + return cmdReload(args[1:]) case "hook": return cmdHook(args[1:]) case "-h", "--help", "help": @@ -73,6 +75,7 @@ Usage: 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 reload Repoint a running server's chords at this binary (live) Inside a session: C-x l list every Claude session for this project @@ -172,6 +175,31 @@ func cmdKill(args []string) error { return nil } +// cmdReload regenerates the isolated tmux config from this binary and re-sources +// it into the running server, so its chords (C-x l/r/n/u) run the current +// claude-mux rather than the store path baked in when the server first started. +// It is meant to be run by home-manager activation after a Nix rebuild: the live +// server's bindings hot-reload to the new binary while every running Claude +// window keeps going untouched. It is a quiet no-op when no server is running. +func cmdReload(args []string) error { + fs := flag.NewFlagSet("reload", flag.ContinueOnError) + sockFlag := fs.String("socket", "", "tmux socket name") + if err := fs.Parse(args); err != nil { + return err + } + if *sockFlag != "" { + os.Setenv("CLAUDE_MUX_SOCKET", *sockFlag) + } + reloaded, err := tmux.New().Reload() + if err != nil { + return err + } + if reloaded { + fmt.Println("reloaded claude-mux bindings") + } + 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 diff --git a/modules/cli/home.nix b/modules/cli/home.nix index 8d58447..be55f6f 100644 --- a/modules/cli/home.nix +++ b/modules/cli/home.nix @@ -109,6 +109,11 @@ ${lib.getExe pkgs.jq} '.remoteControlAtStartup = true' "$f" >"$f.tmp" && mv "$f.tmp" "$f" ''; + # path is persisted at first launch, reset it to hot-reload. + home.activation.claudeMuxReload = '' + ${lib.getExe pkgs.claude-mux} reload || true + ''; + xdg.configFile."claude/CLAUDE.md".text = '' # Global instructions