mirror of
https://github.com/zoriya/flake.git
synced 2026-08-15 18:43:42 +00:00
Make claude-mux faster
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user