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:
81
display.go
81
display.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
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 {
|
func stateColor(state SessionState, noColor bool) string {
|
||||||
if noColor {
|
if noColor {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
178
display_test.go
178
display_test.go
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDisplaySessions_WithSessions(t *testing.T) {
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
205
main.go
205
main.go
@@ -1,98 +1,179 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
i3 "go.i3wm.org/i3/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
noColor := flag.Bool("no-color", false, "Disable colored output")
|
home := os.Getenv("HOME")
|
||||||
flag.Parse()
|
sockPath := filepath.Join(home, ".vmux", "vmux.sock")
|
||||||
|
|
||||||
if os.Getenv("NO_COLOR") != "" {
|
noColor := os.Getenv("NO_COLOR") != ""
|
||||||
*noColor = true
|
|
||||||
}
|
|
||||||
|
|
||||||
args := flag.Args()
|
args := os.Args[1:]
|
||||||
// Also check for --no-color after subcommand (flag stops at first non-flag)
|
|
||||||
|
// Filter --no-color from args
|
||||||
var filteredArgs []string
|
var filteredArgs []string
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
if arg == "--no-color" {
|
if arg == "--no-color" {
|
||||||
*noColor = true
|
noColor = true
|
||||||
} else {
|
} else {
|
||||||
filteredArgs = append(filteredArgs, arg)
|
filteredArgs = append(filteredArgs, arg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(filteredArgs) == 0 || filteredArgs[0] != "list" {
|
if len(filteredArgs) == 0 {
|
||||||
fmt.Fprintf(os.Stderr, "Usage: vmux list [--no-color]\n")
|
printUsage()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
processes, err := FindClaudeProcesses("/proc")
|
switch filteredArgs[0] {
|
||||||
if err != nil {
|
case "list":
|
||||||
fmt.Fprintf(os.Stderr, "Error scanning processes: %v\n", err)
|
if err := EnsureDaemon(sockPath); err != nil {
|
||||||
os.Exit(1)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
}
|
os.Exit(1)
|
||||||
|
}
|
||||||
claudeDir := filepath.Join(os.Getenv("HOME"), ".claude", "projects")
|
client := NewClient(sockPath)
|
||||||
now := time.Now()
|
resp, err := client.Send(Request{Action: "list"})
|
||||||
var sessions []Session
|
|
||||||
|
|
||||||
for _, proc := range processes {
|
|
||||||
jsonlPath, messages, err := FindSessionForProcess(claudeDir, proc)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sessions = append(sessions, Session{
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
Process: proc,
|
os.Exit(1)
|
||||||
State: Unknown,
|
|
||||||
CwdPath: proc.Cwd,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
if !resp.OK {
|
||||||
state := DetectState(messages, now)
|
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error)
|
||||||
preview := ExtractPreview(messages)
|
os.Exit(1)
|
||||||
|
|
||||||
var sessionID, gitBranch string
|
|
||||||
for _, msg := range messages {
|
|
||||||
if msg.SessionID != "" {
|
|
||||||
sessionID = msg.SessionID
|
|
||||||
}
|
|
||||||
if msg.GitBranch != "" {
|
|
||||||
gitBranch = msg.GitBranch
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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{
|
case "stop":
|
||||||
Process: proc,
|
client := NewClient(sockPath)
|
||||||
SessionID: sessionID,
|
resp, err := client.Send(Request{Action: "stop"})
|
||||||
GitBranch: gitBranch,
|
if err != nil {
|
||||||
State: state,
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
Preview: preview,
|
os.Exit(1)
|
||||||
CwdPath: proc.Cwd,
|
}
|
||||||
Worktree: worktree,
|
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.
|
func runDaemon(sockPath string) {
|
||||||
// Falls back to cwd if git fails (not a git repo).
|
home := os.Getenv("HOME")
|
||||||
func resolveWorktree(cwd string) string {
|
procDir := "/proc"
|
||||||
cmd := exec.Command("git", "-C", cwd, "rev-parse", "--show-toplevel")
|
claudeDir := filepath.Join(home, ".claude", "projects")
|
||||||
out, err := cmd.Output()
|
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 {
|
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
|
||||||
|
`)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user