diff --git a/display.go b/display.go index cebe562..b512008 100644 --- a/display.go +++ b/display.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "strings" + "time" ) const ( @@ -50,6 +51,86 @@ func DisplaySessions(w io.Writer, sessions []Session, noColor bool) { } } +// DisplaySessionInfos writes a formatted list of SessionInfo (from daemon) to w. +// Includes workspace, label, and waiting duration. +func DisplaySessionInfos(w io.Writer, sessions []SessionInfo, noColor bool, now time.Time) { + if len(sessions) == 0 { + fmt.Fprintln(w, "No active Claude Code sessions found.") + return + } + + for i, s := range sessions { + if i > 0 { + fmt.Fprintln(w) + } + + stateStr := stateColorStr(s.State, noColor) + s.State + resetIfColor(noColor) + + branch := "" + if s.GitBranch != "" { + branch = " (" + s.GitBranch + ")" + } + + workspace := "" + if s.Workspace != "" { + workspace = " [ws:" + s.Workspace + "]" + } + + label := "" + if s.Label != "" { + label = fmt.Sprintf(" %q", s.Label) + } + + waiting := "" + if s.WaitingSince != nil { + waiting = " (depuis " + formatDuration(now.Sub(*s.WaitingSince)) + ")" + } + + fmt.Fprintf(w, "[%s] %s%s%s%s%s\n", stateStr, s.Cwd, branch, workspace, label, waiting) + + if s.Preview != "" { + lines := strings.Split(s.Preview, "\n") + for _, line := range lines { + fmt.Fprintf(w, " %s\n", line) + } + } + } +} + +// formatDuration returns a human-readable relative duration. +func formatDuration(d time.Duration) string { + if d < time.Minute { + return "< 1 min" + } + + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + + if hours > 0 { + if minutes > 0 { + return fmt.Sprintf("%d h %d min", hours, minutes) + } + return fmt.Sprintf("%d h", hours) + } + return fmt.Sprintf("%d min", minutes) +} + +func stateColorStr(state string, noColor bool) string { + if noColor { + return "" + } + switch state { + case "Working": + return colorGreen + case "Needs Input": + return colorYellow + case "Idle": + return colorGray + default: + return colorGray + } +} + func stateColor(state SessionState, noColor bool) string { if noColor { return "" diff --git a/display_test.go b/display_test.go index aafcc4d..f7f310d 100644 --- a/display_test.go +++ b/display_test.go @@ -4,6 +4,7 @@ import ( "bytes" "strings" "testing" + "time" ) func TestDisplaySessions_WithSessions(t *testing.T) { @@ -113,3 +114,180 @@ func TestDisplaySessions_NoBranch(t *testing.T) { t.Errorf("no branch should mean no parentheses: %q", output) } } + +// --- DisplaySessionInfos tests --- + +func TestDisplayWithWorkspace(t *testing.T) { + sessions := []SessionInfo{ + { + PID: 100, + SessionID: "sess-1", + Cwd: "/home/pierre/Code/vibe/vmux", + GitBranch: "feat-auth", + State: "Working", + Workspace: "3", + }, + } + + var buf bytes.Buffer + DisplaySessionInfos(&buf, sessions, true, time.Now()) + output := buf.String() + + if !strings.Contains(output, "[ws:3]") { + t.Errorf("output should contain [ws:3]: %q", output) + } +} + +func TestDisplayWithLabel(t *testing.T) { + sessions := []SessionInfo{ + { + PID: 100, + SessionID: "sess-1", + Cwd: "/home/user/project", + State: "Working", + Label: "review MR", + }, + } + + var buf bytes.Buffer + DisplaySessionInfos(&buf, sessions, true, time.Now()) + output := buf.String() + + if !strings.Contains(output, `"review MR"`) { + t.Errorf("output should contain quoted label: %q", output) + } +} + +func TestDisplayWithWaitingSince(t *testing.T) { + threeMinAgo := time.Now().Add(-3 * time.Minute) + sessions := []SessionInfo{ + { + PID: 100, + SessionID: "sess-1", + Cwd: "/home/user/project", + State: "Needs Input", + WaitingSince: &threeMinAgo, + }, + } + + var buf bytes.Buffer + now := time.Now() + DisplaySessionInfos(&buf, sessions, true, now) + output := buf.String() + + if !strings.Contains(output, "depuis 3 min") { + t.Errorf("output should contain waiting duration: %q", output) + } +} + +func TestDisplayWithoutOptionalFields(t *testing.T) { + sessions := []SessionInfo{ + { + PID: 100, + SessionID: "sess-1", + Cwd: "/home/user/project", + State: "Working", + }, + } + + var buf bytes.Buffer + DisplaySessionInfos(&buf, sessions, true, time.Now()) + output := buf.String() + + if strings.Contains(output, "[ws:") { + t.Errorf("should not show workspace when empty: %q", output) + } + if strings.Contains(output, `"`) { + t.Errorf("should not show quotes when no label: %q", output) + } + if strings.Contains(output, "depuis") { + t.Errorf("should not show waiting when nil: %q", output) + } +} + +func TestDisplaySessionInfosEmpty(t *testing.T) { + var buf bytes.Buffer + DisplaySessionInfos(&buf, nil, false, time.Now()) + output := buf.String() + + want := "No active Claude Code sessions found.\n" + if output != want { + t.Errorf("output = %q, want %q", output, want) + } +} + +func TestDisplayWithPreview(t *testing.T) { + sessions := []SessionInfo{ + { + PID: 100, + SessionID: "sess-1", + Cwd: "/home/user/project", + State: "Needs Input", + Preview: "What should I do?", + }, + } + + var buf bytes.Buffer + DisplaySessionInfos(&buf, sessions, true, time.Now()) + output := buf.String() + + if !strings.Contains(output, "What should I do?") { + t.Errorf("output should contain preview: %q", output) + } +} + +func TestFormatDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {30 * time.Second, "< 1 min"}, + {3 * time.Minute, "3 min"}, + {65 * time.Minute, "1 h 5 min"}, + {2 * time.Hour, "2 h"}, + {2*time.Hour + 30*time.Minute, "2 h 30 min"}, + } + + for _, tt := range tests { + got := formatDuration(tt.d) + if got != tt.want { + t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want) + } + } +} + +func TestDisplayFullLine(t *testing.T) { + threeMinAgo := time.Now().Add(-3 * time.Minute) + sessions := []SessionInfo{ + { + PID: 100, + SessionID: "sess-1", + Cwd: "/home/pierre/Code/vibe/vmux", + GitBranch: "feat/auth", + State: "Needs Input", + Workspace: "3", + Label: "review MR !456", + WaitingSince: &threeMinAgo, + Preview: "preview line 1\npreview line 2", + }, + } + + var buf bytes.Buffer + DisplaySessionInfos(&buf, sessions, true, time.Now()) + output := buf.String() + + for _, want := range []string{ + "[Needs Input]", + "/home/pierre/Code/vibe/vmux", + "(feat/auth)", + "[ws:3]", + `"review MR !456"`, + "(depuis 3 min)", + "preview line 1", + "preview line 2", + } { + if !strings.Contains(output, want) { + t.Errorf("output missing %q in: %q", want, output) + } + } +} diff --git a/main.go b/main.go index 1032e9a..75fbd1f 100644 --- a/main.go +++ b/main.go @@ -1,98 +1,179 @@ package main import ( - "flag" + "encoding/json" "fmt" + "log" "os" - "os/exec" "path/filepath" "strings" "time" + + i3 "go.i3wm.org/i3/v4" ) func main() { - noColor := flag.Bool("no-color", false, "Disable colored output") - flag.Parse() + home := os.Getenv("HOME") + sockPath := filepath.Join(home, ".vmux", "vmux.sock") - if os.Getenv("NO_COLOR") != "" { - *noColor = true - } + noColor := os.Getenv("NO_COLOR") != "" - args := flag.Args() - // Also check for --no-color after subcommand (flag stops at first non-flag) + args := os.Args[1:] + + // Filter --no-color from args var filteredArgs []string for _, arg := range args { if arg == "--no-color" { - *noColor = true + noColor = true } else { filteredArgs = append(filteredArgs, arg) } } - if len(filteredArgs) == 0 || filteredArgs[0] != "list" { - fmt.Fprintf(os.Stderr, "Usage: vmux list [--no-color]\n") + if len(filteredArgs) == 0 { + printUsage() os.Exit(1) } - processes, err := FindClaudeProcesses("/proc") - if err != nil { - fmt.Fprintf(os.Stderr, "Error scanning processes: %v\n", err) - os.Exit(1) - } - - claudeDir := filepath.Join(os.Getenv("HOME"), ".claude", "projects") - now := time.Now() - var sessions []Session - - for _, proc := range processes { - jsonlPath, messages, err := FindSessionForProcess(claudeDir, proc) + switch filteredArgs[0] { + case "list": + if err := EnsureDaemon(sockPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + client := NewClient(sockPath) + resp, err := client.Send(Request{Action: "list"}) if err != nil { - sessions = append(sessions, Session{ - Process: proc, - State: Unknown, - CwdPath: proc.Cwd, - }) - continue + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) } - - state := DetectState(messages, now) - preview := ExtractPreview(messages) - - var sessionID, gitBranch string - for _, msg := range messages { - if msg.SessionID != "" { - sessionID = msg.SessionID - } - if msg.GitBranch != "" { - gitBranch = msg.GitBranch - } + if !resp.OK { + fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) + os.Exit(1) } + DisplaySessionInfos(os.Stdout, resp.Sessions, noColor, time.Now()) - worktree := resolveWorktree(proc.Cwd) + case "switch": + if len(filteredArgs) < 2 { + fmt.Fprintf(os.Stderr, "Usage: vmux switch \n") + os.Exit(1) + } + query := strings.Join(filteredArgs[1:], " ") + if err := EnsureDaemon(sockPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + client := NewClient(sockPath) + switchArgs, _ := json.Marshal(SwitchArgs{Query: query}) + resp, err := client.Send(Request{Action: "switch", Args: switchArgs}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if !resp.OK { + fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) + os.Exit(1) + } + fmt.Println("Switched.") - _ = jsonlPath + case "label": + if len(filteredArgs) < 3 { + fmt.Fprintf(os.Stderr, "Usage: vmux label \n") + os.Exit(1) + } + sessionID := filteredArgs[1] + label := strings.Join(filteredArgs[2:], " ") + if err := EnsureDaemon(sockPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + client := NewClient(sockPath) + labelArgs, _ := json.Marshal(LabelArgs{SessionID: sessionID, Label: label}) + resp, err := client.Send(Request{Action: "label", Args: labelArgs}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if !resp.OK { + fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) + os.Exit(1) + } + fmt.Println("Label set.") - sessions = append(sessions, Session{ - Process: proc, - SessionID: sessionID, - GitBranch: gitBranch, - State: state, - Preview: preview, - CwdPath: proc.Cwd, - Worktree: worktree, - }) + case "stop": + client := NewClient(sockPath) + resp, err := client.Send(Request{Action: "stop"}) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + if !resp.OK { + fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error) + os.Exit(1) + } + fmt.Println("Daemon stopped.") + + case "daemon": + runDaemon(sockPath) + + default: + printUsage() + os.Exit(1) } - - DisplaySessions(os.Stdout, sessions, *noColor) } -// resolveWorktree uses git to find the worktree root. -// Falls back to cwd if git fails (not a git repo). -func resolveWorktree(cwd string) string { - cmd := exec.Command("git", "-C", cwd, "rev-parse", "--show-toplevel") - out, err := cmd.Output() +func runDaemon(sockPath string) { + home := os.Getenv("HOME") + procDir := "/proc" + claudeDir := filepath.Join(home, ".claude", "projects") + labelsPath := filepath.Join(home, ".vmux", "labels.json") + + if err := os.MkdirAll(filepath.Dir(sockPath), 0o755); err != nil { + log.Fatalf("create vmux dir: %v", err) + } + + labels, err := NewLabelStore(labelsPath) if err != nil { - return cwd + log.Fatalf("load labels: %v", err) } - return strings.TrimSpace(string(out)) + + d := NewDaemon(sockPath, procDir, claudeDir, labels) + + // Wire i3 + X11 workspace resolution (graceful degradation) + x11, err := NewRealX11Resolver() + if err != nil { + log.Printf("Warning: X11 unavailable, no workspace resolution: %v", err) + } else { + d.i3commander = RealI3Commander{} + d.InitWorkspaceResolver(&realI3TreeProvider{}, x11) + } + + if err := d.Start(); err != nil { + log.Fatalf("start daemon: %v", err) + } + + log.Printf("vmux daemon listening on %s", sockPath) + d.Wait() +} + +// realI3TreeProvider wraps the real i3.GetTree call. +type realI3TreeProvider struct{} + +func (p *realI3TreeProvider) GetTree() (i3.Tree, error) { + return i3.GetTree() +} + +func printUsage() { + fmt.Fprintf(os.Stderr, `Usage: vmux [args...] + +Commands: + list List active Claude Code sessions + switch Switch to the workspace of the matching session + label Assign a label to a session + stop Stop the vmux daemon + daemon Run the daemon in foreground (used internally) + +Flags: + --no-color Disable colored output +`) }