From 221a4447e23c322801c9c16f0ed4561b508e4182 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Mon, 23 Mar 2026 21:30:30 +0100 Subject: [PATCH] feat(04-02): add vmux i3bar subcommand with i3status wrapping - runI3Bar: standalone mode (polling 2s) and i3status wrapping mode - i3bar protocol v1: header + infinite JSON array on stdout - queryVmuxBlock: daemon offline fallback in gray - CLI: vmux i3bar auto-detects i3status in PATH - Stdout sync after each write to avoid buffering Co-Authored-By: Claude Opus 4.6 (1M context) --- i3bar.go | 124 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 13 ++++++ 2 files changed, 137 insertions(+) diff --git a/i3bar.go b/i3bar.go index a4eab12..4d7cbd8 100644 --- a/i3bar.go +++ b/i3bar.go @@ -1,8 +1,14 @@ package main import ( + "bufio" + "encoding/json" "fmt" + "log" + "os" + "os/exec" "strings" + "time" ) // I3BarBlock represents a single block in the i3bar protocol. @@ -50,3 +56,121 @@ func formatI3BarBlocks(sessions []SessionInfo) []I3BarBlock { return []I3BarBlock{{FullText: text, Color: color, Name: "vmux"}} } + +// 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 { + client := NewClient(sockPath) + resp, err := client.Send(Request{Action: "list"}) + if err != nil { + return I3BarBlock{FullText: "vmux: daemon offline", Color: "#888888", Name: "vmux"} + } + if !resp.OK { + return I3BarBlock{FullText: "vmux: error", Color: "#888888", Name: "vmux"} + } + blocks := formatI3BarBlocks(resp.Sessions) + return blocks[0] +} + +// 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) { + 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) + 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/main.go b/main.go index 35c5141..20541c8 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -141,6 +142,17 @@ func main() { } fmt.Println("Daemon stopped.") + case "i3bar": + if err := EnsureDaemon(sockPath); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + i3statusCmd := "" + if _, err := exec.LookPath("i3status"); err == nil { + i3statusCmd = "i3status" + } + runI3Bar(sockPath, i3statusCmd) + case "daemon": runDaemon(sockPath) @@ -199,6 +211,7 @@ Commands: switch Switch to the workspace of the matching session label Assign a label to a session focus Suppress notifications for N minutes + i3bar Output i3bar JSON (use as status_command in i3 config) stop Stop the vmux daemon daemon Run the daemon in foreground (used internally)