Make claude-mux faster

This commit is contained in:
2026-07-28 19:33:19 +02:00
parent 44ef7dbd6d
commit 82789f47ed
7 changed files with 256 additions and 28 deletions
+1
View File
@@ -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
```
+96
View File
@@ -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)
}
}
+52 -8
View File
@@ -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
}
+20
View File
@@ -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).
+54 -20
View File
@@ -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")
+28
View File
@@ -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
+5
View File
@@ -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