From fd246f046be4b2b280abf238a4ab3ad2315078a4 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Mon, 23 Mar 2026 23:04:37 +0100 Subject: [PATCH] feat(04-02): i3bar widget with per-session colors, workspace prefix, wait type and duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - One i3bar block per session (individual colors: red/green/gray) - Workspace prefix: 10-vmux[W], 3-auth[? 3m] - Wait type markers: ⚡ permission, ? question, ! generic - Wait duration: [⚡ 30s], [? 3m], [? 1h15m] - Standalone mode only (separate bar, no i3status wrapping) - Removed isTerminal and i3status wrapping code --- i3bar.go | 203 +++++++++++++++++++------------------------------- i3bar_test.go | 168 +++++++++++++++++++++++------------------ main.go | 8 +- 3 files changed, 176 insertions(+), 203 deletions(-) diff --git a/i3bar.go b/i3bar.go index 4d7cbd8..66a3929 100644 --- a/i3bar.go +++ b/i3bar.go @@ -1,13 +1,10 @@ package main import ( - "bufio" "encoding/json" "fmt" "log" "os" - "os/exec" - "strings" "time" ) @@ -20,157 +17,113 @@ type I3BarBlock struct { Markup string `json:"markup,omitempty"` } -// formatI3BarBlocks builds the vmux status block for i3bar from session data. -func formatI3BarBlocks(sessions []SessionInfo) []I3BarBlock { +// formatI3BarBlocks builds one i3bar block per session, each with its own color. +// For Needs Input sessions: shows wait type icon and duration. +func formatI3BarBlocks(sessions []SessionInfo, now time.Time) []I3BarBlock { if len(sessions) == 0 { - return []I3BarBlock{{FullText: "vmux: no sessions", Color: "#00ff00", Name: "vmux"}} + return []I3BarBlock{{FullText: "vmux: no sessions", Color: "#888888", Name: "vmux"}} } - hasWaiting := false - parts := make([]string, 0, len(sessions)) - + blocks := make([]I3BarBlock, 0, len(sessions)) for _, s := range sessions { name := shortName(s) + if s.Workspace != "" { + name = s.Workspace + "-" + name + } + + var marker, color string switch s.State { case "Needs Input": - parts = append(parts, name+"[!]") - hasWaiting = true + marker = waitTypeMarker(s.WaitType) + if s.WaitingSince != nil { + marker += " " + shortDuration(now.Sub(*s.WaitingSince)) + } + marker = "[" + marker + "]" + color = "#ff0000" case "Working": - parts = append(parts, name+"[W]") + marker = "[W]" + color = "#00ff00" case "Idle": - parts = append(parts, name+"[I]") + marker = "[I]" + color = "#888888" + default: + marker = "[?]" + color = "#888888" } + + blocks = append(blocks, I3BarBlock{ + FullText: name + marker, + Color: color, + Name: "vmux-" + s.SessionID, + }) } - var text string - if !hasWaiting { - text = fmt.Sprintf("vmux: all working (%d)", len(sessions)) - } else { - text = "vmux: " + strings.Join(parts, " ") - } - - color := "#00ff00" - if hasWaiting { - color = "#ff0000" - } - - return []I3BarBlock{{FullText: text, Color: color, Name: "vmux"}} + return blocks } -// queryVmuxBlock fetches sessions from daemon and returns the vmux i3bar block. -// Returns a "daemon offline" block if the daemon is unreachable. -func queryVmuxBlock(sockPath string) I3BarBlock { +// waitTypeMarker returns a short icon for the wait type. +func waitTypeMarker(waitType string) string { + switch waitType { + case "permission": + return "⚡" + case "question": + return "?" + default: + return "!" + } +} + +// shortDuration formats a duration as compact string for i3bar. +func shortDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + m := int(d.Minutes()) + if m < 60 { + return fmt.Sprintf("%dm", m) + } + return fmt.Sprintf("%dh%dm", m/60, m%60) +} + +// queryVmuxBlocks fetches sessions from daemon and returns i3bar blocks. +func queryVmuxBlocks(sockPath string) []I3BarBlock { client := NewClient(sockPath) resp, err := client.Send(Request{Action: "list"}) if err != nil { - return I3BarBlock{FullText: "vmux: daemon offline", Color: "#888888", Name: "vmux"} + return []I3BarBlock{{FullText: "vmux: offline", Color: "#888888", Name: "vmux"}} } if !resp.OK { - return I3BarBlock{FullText: "vmux: error", Color: "#888888", Name: "vmux"} + return []I3BarBlock{{FullText: "vmux: error", Color: "#888888", Name: "vmux"}} } - blocks := formatI3BarBlocks(resp.Sessions) - return blocks[0] + return formatI3BarBlocks(resp.Sessions, time.Now()) } -// writeI3BarLine writes a JSON array of blocks as an i3bar protocol line. -// Uses os.Stdout.Write + Sync to avoid buffering issues. -func writeI3BarLine(blocks []I3BarBlock, first bool) { - data, err := json.Marshal(blocks) - if err != nil { - log.Printf("i3bar marshal error: %v", err) - return - } - - var line string - if first { - line = string(data) + "\n" - } else { - line = "," + string(data) + "\n" - } - os.Stdout.Write([]byte(line)) - os.Stdout.Sync() -} - -// runI3Bar runs the i3bar protocol loop on stdout. -// If i3statusCmd is non-empty, it wraps i3status output and prepends vmux blocks. -// If empty, runs in standalone mode with periodic polling. -func runI3Bar(sockPath, i3statusCmd string) { - if i3statusCmd != "" { - runI3BarWrapped(sockPath, i3statusCmd) - // If i3status exits, fall through to standalone - } - runI3BarStandalone(sockPath) -} - -func runI3BarStandalone(sockPath string) { +// runI3Bar runs the i3bar protocol loop on stdout, polling the daemon every 2s. +func runI3Bar(sockPath string) { os.Stdout.Write([]byte("{\"version\":1}\n")) os.Stdout.Write([]byte("[\n")) os.Stdout.Sync() first := true for { - block := queryVmuxBlock(sockPath) - writeI3BarLine([]I3BarBlock{block}, first) + blocks := queryVmuxBlocks(sockPath) + data, err := json.Marshal(blocks) + if err != nil { + log.Printf("i3bar marshal error: %v", err) + time.Sleep(2 * time.Second) + continue + } + + var line string + if first { + line = string(data) + "\n" + } else { + line = "," + string(data) + "\n" + } + os.Stdout.Write([]byte(line)) + os.Stdout.Sync() + first = false time.Sleep(2 * time.Second) } } - -func runI3BarWrapped(sockPath, i3statusCmd string) { - cmd := exec.Command("sh", "-c", i3statusCmd) - stdout, err := cmd.StdoutPipe() - if err != nil { - log.Printf("i3bar: cannot pipe i3status stdout: %v", err) - return - } - cmd.Stderr = os.Stderr - - if err := cmd.Start(); err != nil { - log.Printf("i3bar: cannot start i3status: %v", err) - return - } - - scanner := bufio.NewScanner(stdout) - scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) - - // Forward header line (e.g. {"version":1}) - if !scanner.Scan() { - log.Print("i3bar: i3status produced no output") - return - } - os.Stdout.Write([]byte(scanner.Text() + "\n")) - os.Stdout.Sync() - - // Forward opening bracket - if !scanner.Scan() { - log.Print("i3bar: i3status produced no opening bracket") - return - } - os.Stdout.Write([]byte(scanner.Text() + "\n")) - os.Stdout.Sync() - - first := true - for scanner.Scan() { - line := scanner.Text() - trimmed := strings.TrimLeft(line, ",") - if trimmed == "" { - continue - } - - var blocks []I3BarBlock - if err := json.Unmarshal([]byte(trimmed), &blocks); err != nil { - // Not a JSON array line, forward as-is - os.Stdout.Write([]byte(line + "\n")) - os.Stdout.Sync() - continue - } - - vmuxBlock := queryVmuxBlock(sockPath) - blocks = append([]I3BarBlock{vmuxBlock}, blocks...) - - writeI3BarLine(blocks, first) - first = false - } - - cmd.Wait() -} diff --git a/i3bar_test.go b/i3bar_test.go index 27d161e..0351f9c 100644 --- a/i3bar_test.go +++ b/i3bar_test.go @@ -2,24 +2,51 @@ package main import ( "testing" + "time" ) +var i3barTestNow = time.Date(2026, 3, 24, 12, 0, 0, 0, time.UTC) + func TestFormatI3BarBlocks_MixedStates(t *testing.T) { + waiting := i3barTestNow.Add(-3 * time.Minute) sessions := []SessionInfo{ - {Cwd: "/home/pierre/Code/auth", State: "Needs Input"}, - {Cwd: "/home/pierre/Code/portal", State: "Working"}, - {Cwd: "/home/pierre/Code/neia", State: "Idle"}, + {Cwd: "/home/pierre/Code/auth", Workspace: "3", State: "Needs Input", WaitType: "question", WaitingSince: &waiting}, + {Cwd: "/home/pierre/Code/portal", Workspace: "2", State: "Working"}, + {Cwd: "/home/pierre/Code/neia", Workspace: "5", State: "Idle"}, } - blocks := formatI3BarBlocks(sessions) - if len(blocks) != 1 { - t.Fatalf("expected 1 block, got %d", len(blocks)) + blocks := formatI3BarBlocks(sessions, i3barTestNow) + if len(blocks) != 3 { + t.Fatalf("expected 3 blocks, got %d", len(blocks)) } - want := "vmux: auth[!] portal[W] neia[I]" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) + if blocks[0].FullText != "3-auth[? 3m]" || blocks[0].Color != "#ff0000" { + t.Errorf("block 0: %q %q", blocks[0].FullText, blocks[0].Color) } - if blocks[0].Color != "#ff0000" { - t.Errorf("color = %q, want #ff0000", blocks[0].Color) + if blocks[1].FullText != "2-portal[W]" || blocks[1].Color != "#00ff00" { + t.Errorf("block 1: %q %q", blocks[1].FullText, blocks[1].Color) + } + if blocks[2].FullText != "5-neia[I]" || blocks[2].Color != "#888888" { + t.Errorf("block 2: %q %q", blocks[2].FullText, blocks[2].Color) + } +} + +func TestFormatI3BarBlocks_PermissionMarker(t *testing.T) { + waiting := i3barTestNow.Add(-30 * time.Second) + sessions := []SessionInfo{ + {Cwd: "/a/proj", Workspace: "1", State: "Needs Input", WaitType: "permission", WaitingSince: &waiting}, + } + blocks := formatI3BarBlocks(sessions, i3barTestNow) + if blocks[0].FullText != "1-proj[⚡ 30s]" { + t.Errorf("full_text = %q, want %q", blocks[0].FullText, "1-proj[⚡ 30s]") + } +} + +func TestFormatI3BarBlocks_NeedsInputNoWaitType(t *testing.T) { + sessions := []SessionInfo{ + {Cwd: "/a/proj", State: "Needs Input"}, + } + blocks := formatI3BarBlocks(sessions, i3barTestNow) + if blocks[0].FullText != "proj[!]" { + t.Errorf("full_text = %q, want %q", blocks[0].FullText, "proj[!]") } } @@ -27,48 +54,38 @@ func TestFormatI3BarBlocks_AllWorking(t *testing.T) { sessions := []SessionInfo{ {Cwd: "/a/b/one", State: "Working"}, {Cwd: "/a/b/two", State: "Working"}, - {Cwd: "/a/b/three", State: "Working"}, } - blocks := formatI3BarBlocks(sessions) - want := "vmux: all working (3)" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) + blocks := formatI3BarBlocks(sessions, i3barTestNow) + if len(blocks) != 2 { + t.Fatalf("expected 2 blocks, got %d", len(blocks)) } - if blocks[0].Color != "#00ff00" { - t.Errorf("color = %q, want #00ff00", blocks[0].Color) + if blocks[0].FullText != "one[W]" || blocks[0].Color != "#00ff00" { + t.Errorf("block 0: %q %q", blocks[0].FullText, blocks[0].Color) } } func TestFormatI3BarBlocks_NoSessions(t *testing.T) { - blocks := formatI3BarBlocks(nil) - want := "vmux: no sessions" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) - } - if blocks[0].Color != "#00ff00" { - t.Errorf("color = %q, want #00ff00", blocks[0].Color) + blocks := formatI3BarBlocks(nil, i3barTestNow) + if len(blocks) != 1 || blocks[0].FullText != "vmux: no sessions" { + t.Errorf("full_text = %q", blocks[0].FullText) } } -func TestFormatI3BarBlocks_ColorRed(t *testing.T) { +func TestFormatI3BarBlocks_PerSessionColor(t *testing.T) { sessions := []SessionInfo{ - {Cwd: "/a/b/x", State: "Working"}, - {Cwd: "/a/b/y", State: "Needs Input"}, + {Cwd: "/a/x", State: "Working"}, + {Cwd: "/a/y", State: "Needs Input"}, + {Cwd: "/a/z", State: "Idle"}, } - blocks := formatI3BarBlocks(sessions) - if blocks[0].Color != "#ff0000" { - t.Errorf("color = %q, want #ff0000", blocks[0].Color) - } -} - -func TestFormatI3BarBlocks_ColorGreen(t *testing.T) { - sessions := []SessionInfo{ - {Cwd: "/a/b/x", State: "Working"}, - {Cwd: "/a/b/y", State: "Idle"}, - } - blocks := formatI3BarBlocks(sessions) + blocks := formatI3BarBlocks(sessions, i3barTestNow) if blocks[0].Color != "#00ff00" { - t.Errorf("color = %q, want #00ff00", blocks[0].Color) + t.Errorf("Working color = %q, want #00ff00", blocks[0].Color) + } + if blocks[1].Color != "#ff0000" { + t.Errorf("Needs Input color = %q, want #ff0000", blocks[1].Color) + } + if blocks[2].Color != "#888888" { + t.Errorf("Idle color = %q, want #888888", blocks[2].Color) } } @@ -76,54 +93,61 @@ func TestFormatI3BarBlocks_UsesLabel(t *testing.T) { sessions := []SessionInfo{ {Cwd: "/home/pierre/Code/auth-service", Label: "auth", State: "Working"}, } - blocks := formatI3BarBlocks(sessions) - want := "vmux: all working (1)" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) + blocks := formatI3BarBlocks(sessions, i3barTestNow) + if blocks[0].FullText != "auth[W]" { + t.Errorf("full_text = %q, want %q", blocks[0].FullText, "auth[W]") } } -func TestFormatI3BarBlocks_UsesLabelInMixed(t *testing.T) { +func TestFormatI3BarBlocks_WorkspacePrefix(t *testing.T) { sessions := []SessionInfo{ - {Cwd: "/home/pierre/Code/auth-service", Label: "auth", State: "Needs Input"}, + {Cwd: "/home/pierre/Code/vibe/vmux", Workspace: "10", State: "Working"}, } - blocks := formatI3BarBlocks(sessions) - want := "vmux: auth[!]" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) + blocks := formatI3BarBlocks(sessions, i3barTestNow) + if blocks[0].FullText != "10-vmux[W]" { + t.Errorf("full_text = %q, want %q", blocks[0].FullText, "10-vmux[W]") } } -func TestFormatI3BarBlocks_UsesCwdBase(t *testing.T) { +func TestFormatI3BarBlocks_LongWaitDuration(t *testing.T) { + waiting := i3barTestNow.Add(-75 * time.Minute) sessions := []SessionInfo{ - {Cwd: "/home/pierre/Code/vibe/vmux", State: "Needs Input"}, + {Cwd: "/a/proj", Workspace: "2", State: "Needs Input", WaitType: "question", WaitingSince: &waiting}, } - blocks := formatI3BarBlocks(sessions) - want := "vmux: vmux[!]" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) + blocks := formatI3BarBlocks(sessions, i3barTestNow) + if blocks[0].FullText != "2-proj[? 1h15m]" { + t.Errorf("full_text = %q, want %q", blocks[0].FullText, "2-proj[? 1h15m]") } } -func TestFormatI3BarBlocks_NeedsInputMarker(t *testing.T) { - sessions := []SessionInfo{ - {Cwd: "/a/proj", State: "Needs Input"}, +func TestShortDuration(t *testing.T) { + tests := []struct { + d time.Duration + want string + }{ + {10 * time.Second, "10s"}, + {59 * time.Second, "59s"}, + {1 * time.Minute, "1m"}, + {5 * time.Minute, "5m"}, + {65 * time.Minute, "1h5m"}, + {120 * time.Minute, "2h0m"}, } - blocks := formatI3BarBlocks(sessions) - want := "vmux: proj[!]" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) + for _, tt := range tests { + got := shortDuration(tt.d) + if got != tt.want { + t.Errorf("shortDuration(%v) = %q, want %q", tt.d, got, tt.want) + } } } -func TestFormatI3BarBlocks_IdleMarker(t *testing.T) { - sessions := []SessionInfo{ - {Cwd: "/a/proj", State: "Idle"}, - {Cwd: "/a/other", State: "Needs Input"}, +func TestWaitTypeMarker(t *testing.T) { + if waitTypeMarker("permission") != "⚡" { + t.Error("permission should be ⚡") } - blocks := formatI3BarBlocks(sessions) - want := "vmux: proj[I] other[!]" - if blocks[0].FullText != want { - t.Errorf("full_text = %q, want %q", blocks[0].FullText, want) + if waitTypeMarker("question") != "?" { + t.Error("question should be ?") + } + if waitTypeMarker("") != "!" { + t.Error("empty should be !") } } diff --git a/main.go b/main.go index 20541c8..a3c6549 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -147,11 +146,7 @@ func main() { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } - i3statusCmd := "" - if _, err := exec.LookPath("i3status"); err == nil { - i3statusCmd = "i3status" - } - runI3Bar(sockPath, i3statusCmd) + runI3Bar(sockPath) case "daemon": runDaemon(sockPath) @@ -203,6 +198,7 @@ func (p *realI3TreeProvider) GetTree() (i3.Tree, error) { return i3.GetTree() } + func printUsage() { fmt.Fprintf(os.Stderr, `Usage: vmux [args...]