diff --git a/display.go b/display.go new file mode 100644 index 0000000..cebe562 --- /dev/null +++ b/display.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +const ( + colorReset = "\033[0m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorRed = "\033[31m" + colorGray = "\033[90m" + colorBold = "\033[1m" +) + +// DisplaySessions writes a formatted list of sessions to w. +func DisplaySessions(w io.Writer, sessions []Session, noColor bool) { + 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) + } + + stateLabel := stateColor(s.State, noColor) + s.State.String() + resetIfColor(noColor) + + branch := "" + if s.GitBranch != "" { + branch = " (" + s.GitBranch + ")" + } + + worktreeInfo := "" + if s.Worktree != "" && s.Worktree != s.CwdPath { + worktreeInfo = " [worktree: " + s.Worktree + "]" + } + + fmt.Fprintf(w, "[%s] %s%s%s\n", stateLabel, s.CwdPath, branch, worktreeInfo) + + if s.Preview != "" { + lines := strings.Split(s.Preview, "\n") + for _, line := range lines { + fmt.Fprintf(w, " %s\n", line) + } + } + } +} + +func stateColor(state SessionState, noColor bool) string { + if noColor { + return "" + } + switch state { + case Working: + return colorGreen + case NeedsInput: + return colorYellow + case Idle: + return colorGray + default: + return colorGray + } +} + +func resetIfColor(noColor bool) string { + if noColor { + return "" + } + return colorReset +} diff --git a/display_test.go b/display_test.go new file mode 100644 index 0000000..aafcc4d --- /dev/null +++ b/display_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestDisplaySessions_WithSessions(t *testing.T) { + sessions := []Session{ + { + Process: Process{PID: 100, Cwd: "/home/user/project-a"}, + State: Working, + CwdPath: "/home/user/project-a", + GitBranch: "feat-login", + Preview: "Implementing auth...", + }, + { + Process: Process{PID: 200, Cwd: "/home/user/project-b"}, + State: NeedsInput, + CwdPath: "/home/user/project-b", + GitBranch: "main", + Preview: "What should I do next?", + }, + } + + var buf bytes.Buffer + DisplaySessions(&buf, sessions, false) + output := buf.String() + + for _, want := range []string{ + "/home/user/project-a", + "feat-login", + "Working", + "Implementing auth...", + "/home/user/project-b", + "main", + "Needs Input", + "What should I do next?", + } { + if !strings.Contains(output, want) { + t.Errorf("output missing %q", want) + } + } +} + +func TestDisplaySessions_NoColor(t *testing.T) { + sessions := []Session{ + { + Process: Process{PID: 100, Cwd: "/tmp"}, + State: Working, + CwdPath: "/tmp", + GitBranch: "main", + }, + } + + var buf bytes.Buffer + DisplaySessions(&buf, sessions, true) + output := buf.String() + + if strings.Contains(output, "\033") { + t.Errorf("noColor=true but output contains ANSI escape: %q", output) + } + if !strings.Contains(output, "Working") { + t.Error("output missing state label") + } +} + +func TestDisplaySessions_Empty(t *testing.T) { + var buf bytes.Buffer + DisplaySessions(&buf, nil, false) + output := buf.String() + + want := "No active Claude Code sessions found.\n" + if output != want { + t.Errorf("output = %q, want %q", output, want) + } +} + +func TestDisplaySessions_WorktreeDiffers(t *testing.T) { + sessions := []Session{ + { + Process: Process{PID: 100, Cwd: "/home/user/repo/src"}, + State: Working, + CwdPath: "/home/user/repo/src", + Worktree: "/home/user/repo", + }, + } + + var buf bytes.Buffer + DisplaySessions(&buf, sessions, true) + output := buf.String() + + if !strings.Contains(output, "[worktree: /home/user/repo]") { + t.Errorf("output should show worktree when different from cwd: %q", output) + } +} + +func TestDisplaySessions_NoBranch(t *testing.T) { + sessions := []Session{ + { + Process: Process{PID: 100, Cwd: "/tmp"}, + State: Unknown, + CwdPath: "/tmp", + }, + } + + var buf bytes.Buffer + DisplaySessions(&buf, sessions, true) + output := buf.String() + + if strings.Contains(output, "(") { + t.Errorf("no branch should mean no parentheses: %q", output) + } +} diff --git a/main.go b/main.go index 6a0f815..1032e9a 100644 --- a/main.go +++ b/main.go @@ -1,5 +1,98 @@ package main +import ( + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + func main() { - // Placeholder - will be implemented in Task 2 + noColor := flag.Bool("no-color", false, "Disable colored output") + flag.Parse() + + if os.Getenv("NO_COLOR") != "" { + *noColor = true + } + + args := flag.Args() + // Also check for --no-color after subcommand (flag stops at first non-flag) + var filteredArgs []string + for _, arg := range args { + if arg == "--no-color" { + *noColor = true + } else { + filteredArgs = append(filteredArgs, arg) + } + } + + if len(filteredArgs) == 0 || filteredArgs[0] != "list" { + fmt.Fprintf(os.Stderr, "Usage: vmux list [--no-color]\n") + 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) + if err != nil { + sessions = append(sessions, Session{ + Process: proc, + State: Unknown, + CwdPath: proc.Cwd, + }) + continue + } + + 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 + } + } + + worktree := resolveWorktree(proc.Cwd) + + _ = jsonlPath + + sessions = append(sessions, Session{ + Process: proc, + SessionID: sessionID, + GitBranch: gitBranch, + State: state, + Preview: preview, + CwdPath: proc.Cwd, + Worktree: worktree, + }) + } + + 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() + if err != nil { + return cwd + } + return strings.TrimSpace(string(out)) }