feat(04-02): i3bar widget with per-session colors, workspace prefix, wait type and duration

- 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
This commit is contained in:
Pierre Martin
2026-03-23 23:04:37 +01:00
parent 221a4447e2
commit fd246f046b
3 changed files with 176 additions and 203 deletions

203
i3bar.go
View File

@@ -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()
}