feat(02-03): main.go subcommands + display enrichi avec workspace, label, duree

- main.go dispatch list/switch/label/stop/daemon via client socket
- DisplaySessionInfos affiche [ws:N], label entre guillemets, "depuis X min"
- formatDuration pour duree relative humaine (< 1 min, 3 min, 1 h 5 min)
- Daemon mode integre i3 + X11 avec degradation gracieuse
- Tests complets pour tous les champs optionnels du display
This commit is contained in:
Pierre Martin
2026-03-23 17:54:18 +01:00
parent a79a0e154c
commit 170790fcda
3 changed files with 402 additions and 62 deletions

205
main.go
View File

@@ -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 <query>\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 <session-id> <text...>\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 <command> [args...]
Commands:
list List active Claude Code sessions
switch <query> Switch to the workspace of the matching session
label <id> <text> 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
`)
}