Pi Dashboard TUI viewer — Go + Bubble Tea. Reads pi agent state files, cross-machine SSH pull, Enter=tmux attach, d=Hunk diff, Tab=Tuxedo tasks
This commit is contained in:
451
main.go
Normal file
451
main.go
Normal file
@@ -0,0 +1,451 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/help"
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// Styles
|
||||
var (
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#00d4ff")).
|
||||
Padding(0, 1)
|
||||
|
||||
statusRunning = lipgloss.NewStyle().Foreground(lipgloss.Color("#00ff88")).SetString("● running")
|
||||
statusCompleted = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("✓ done")
|
||||
statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).SetString("○ idle")
|
||||
statusBlocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).SetString("⚠ blocked")
|
||||
statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")).SetString("✕ error")
|
||||
|
||||
infoStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#aaaaaa")).
|
||||
Padding(0, 1)
|
||||
|
||||
footerStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#888888")).
|
||||
Padding(0, 1)
|
||||
)
|
||||
|
||||
// Model
|
||||
type model struct {
|
||||
table table.Model
|
||||
help help.Model
|
||||
agents []AgentState
|
||||
config Config
|
||||
lastPoll time.Time
|
||||
err error
|
||||
ready bool
|
||||
windowWidth int
|
||||
}
|
||||
|
||||
type pollMsg struct{}
|
||||
|
||||
func initialModel(cfg Config) model {
|
||||
columns := []table.Column{
|
||||
{Title: "Machine", Width: 12},
|
||||
{Title: "Session", Width: 16},
|
||||
{Title: "Status", Width: 10},
|
||||
{Title: "Model", Width: 18},
|
||||
{Title: "Duration", Width: 10},
|
||||
{Title: "Tools", Width: 6},
|
||||
{Title: "Task", Width: 30},
|
||||
}
|
||||
|
||||
t := table.New(
|
||||
table.WithColumns(columns),
|
||||
table.WithFocused(true),
|
||||
table.WithHeight(20),
|
||||
)
|
||||
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("240")).
|
||||
BorderBottom(true).
|
||||
Bold(false)
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color("#ffffff")).
|
||||
Background(lipgloss.Color("#334455")).
|
||||
Bold(false)
|
||||
t.SetStyles(s)
|
||||
|
||||
return model{
|
||||
table: t,
|
||||
help: help.New(),
|
||||
agents: []AgentState{},
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) fetchAllAgents() ([]AgentState, error) {
|
||||
var all []AgentState
|
||||
|
||||
// Local
|
||||
local, err := readLocalStates(m.config.Local.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("local: %w", err)
|
||||
}
|
||||
all = append(all, local...)
|
||||
|
||||
// Remote
|
||||
for _, r := range m.config.Remote {
|
||||
remote, err := readRemoteStates(r)
|
||||
if err != nil {
|
||||
// Skip unreachable remotes silently
|
||||
continue
|
||||
}
|
||||
all = append(all, remote...)
|
||||
}
|
||||
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(pollNow(), tick())
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.windowWidth = msg.Width
|
||||
m.help.Width = msg.Width
|
||||
m.table.SetWidth(msg.Width - 4)
|
||||
m.ready = true
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch {
|
||||
case key.Matches(msg, keys.Quit):
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, keys.Refresh):
|
||||
return m, pollNow()
|
||||
case key.Matches(msg, keys.Attach):
|
||||
return m, attachToAgent(m)
|
||||
case key.Matches(msg, keys.Hunk):
|
||||
return m, openHunk(m)
|
||||
case key.Matches(msg, keys.Tuxedo):
|
||||
return m, openTuxedo(m)
|
||||
}
|
||||
|
||||
case pollMsg:
|
||||
agents, err := m.fetchAllAgents()
|
||||
if err != nil {
|
||||
m.err = err
|
||||
} else {
|
||||
m.err = nil
|
||||
m.agents = agents
|
||||
m.lastPoll = time.Now()
|
||||
m.updateTable()
|
||||
}
|
||||
return m, tick()
|
||||
|
||||
case errMsg:
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var cmd tea.Cmd
|
||||
m.table, cmd = m.table.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
if !m.ready {
|
||||
return "\n Loading Pi Dashboard..."
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Header
|
||||
summary := fmt.Sprintf("%d agent(s)", len(m.agents))
|
||||
if len(m.config.Remote) > 0 {
|
||||
summary += fmt.Sprintf(" │ %d remote(s)", len(m.config.Remote))
|
||||
}
|
||||
b.WriteString(headerStyle.Render(fmt.Sprintf(
|
||||
"╡ Pi Dashboard │ %s │ %s ╞",
|
||||
summary,
|
||||
time.Now().Format("15:04:05"),
|
||||
)))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// Error
|
||||
if m.err != nil {
|
||||
b.WriteString(lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#ff4444")).
|
||||
Render(fmt.Sprintf("⚠ %v\n\n", m.err)))
|
||||
}
|
||||
|
||||
// Table
|
||||
b.WriteString(m.table.View())
|
||||
b.WriteString("\n")
|
||||
|
||||
// Keybindings help
|
||||
b.WriteString(footerStyle.Render(
|
||||
"↑↓ navigate · Enter attach · d hunk · Tab tuxedo · r refresh · q quit",
|
||||
))
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *model) updateTable() {
|
||||
var rows []table.Row
|
||||
for _, a := range m.agents {
|
||||
status := statusStr(a)
|
||||
task := strings.ReplaceAll(a.CurrentTask, "\n", " ")
|
||||
if len(task) > 30 {
|
||||
task = task[:28] + ".."
|
||||
}
|
||||
rows = append(rows, table.Row{
|
||||
a.MachineShort(),
|
||||
a.Session,
|
||||
status,
|
||||
a.ShortModel(),
|
||||
a.DurationStr(),
|
||||
fmt.Sprintf("%d", a.ToolCalls),
|
||||
task,
|
||||
})
|
||||
}
|
||||
m.table.SetRows(rows)
|
||||
|
||||
// Auto-height
|
||||
h := len(rows) + 2
|
||||
if h < 5 {
|
||||
h = 5
|
||||
}
|
||||
if h > 30 {
|
||||
h = 30
|
||||
}
|
||||
m.table.SetHeight(h)
|
||||
}
|
||||
|
||||
// ── Commands ─────────────────────────────────────────────────────
|
||||
|
||||
func tick() tea.Cmd {
|
||||
return tea.Tick(2*time.Second, func(t time.Time) tea.Msg {
|
||||
return pollMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func pollNow() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return pollMsg{}
|
||||
}
|
||||
}
|
||||
|
||||
type errMsg struct{ err error }
|
||||
|
||||
func attachToAgent(m model) tea.Cmd {
|
||||
if len(m.agents) == 0 {
|
||||
return nil
|
||||
}
|
||||
row := m.table.Cursor()
|
||||
if row < 0 || row >= len(m.agents) {
|
||||
return nil
|
||||
}
|
||||
a := m.agents[row]
|
||||
name := a.ConnectionName
|
||||
if name == "" {
|
||||
name = a.Session
|
||||
}
|
||||
|
||||
if a.IsLocal() {
|
||||
return tea.ExecProcess(
|
||||
exec.Command("tmux", "attach-session", "-t", name),
|
||||
func(err error) tea.Msg {
|
||||
if err != nil { return errMsg{err} }
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Remote agent: find SSH config
|
||||
for _, r := range m.config.Remote {
|
||||
// Match by hostname in the machine field
|
||||
if strings.Contains(a.Machine, r.Host) || strings.Contains(r.Host, a.Machine) {
|
||||
user := r.User
|
||||
if user == "" { user = "sam" }
|
||||
return tea.ExecProcess(
|
||||
exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", user, r.Host),
|
||||
"tmux", "attach-session", "-t", name),
|
||||
func(err error) tea.Msg {
|
||||
if err != nil { return errMsg{err} }
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: show the machine name
|
||||
m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine)
|
||||
return nil
|
||||
}
|
||||
|
||||
func openHunk(m model) tea.Cmd {
|
||||
if len(m.agents) == 0 {
|
||||
return nil
|
||||
}
|
||||
row := m.table.Cursor()
|
||||
if row < 0 || row >= len(m.agents) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to find a worktree or repo path
|
||||
// For now, just run hunk in the current directory
|
||||
return tea.ExecProcess(exec.Command("hunk", "diff", "--watch"), func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func openTuxedo(m model) tea.Cmd {
|
||||
home, _ := os.UserHomeDir()
|
||||
todoFile := home + "/.pi/agent/dashboard/tasks/todo.txt"
|
||||
|
||||
// Ensure the file exists before opening
|
||||
if _, err := os.Stat(todoFile); os.IsNotExist(err) {
|
||||
// Create an empty todo.txt
|
||||
os.WriteFile(todoFile, []byte(""), 0644)
|
||||
}
|
||||
|
||||
return tea.ExecProcess(
|
||||
exec.Command("tuxedo", todoFile),
|
||||
func(err error) tea.Msg {
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// ── Keybindings ──────────────────────────────────────────────────
|
||||
|
||||
type keyMap struct {
|
||||
Quit key.Binding
|
||||
Refresh key.Binding
|
||||
Attach key.Binding
|
||||
Hunk key.Binding
|
||||
Tuxedo key.Binding
|
||||
}
|
||||
|
||||
var keys = keyMap{
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||
Attach: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "attach to agent")),
|
||||
Hunk: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "hunk diff")),
|
||||
Tuxedo: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "tuxedo tasks")),
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
func statusStr(a AgentState) string {
|
||||
switch a.Status {
|
||||
case "running":
|
||||
return "● running"
|
||||
case "completed":
|
||||
return "✓ done"
|
||||
case "error":
|
||||
return "✕ error"
|
||||
case "blocked":
|
||||
return "⚠ blocked"
|
||||
default:
|
||||
return "○ idle"
|
||||
}
|
||||
}
|
||||
|
||||
func printAgentList(agents []AgentState) {
|
||||
if len(agents) == 0 {
|
||||
fmt.Println("No agents found in ~/.pi/agent/dashboard/")
|
||||
return
|
||||
}
|
||||
fmt.Printf("%-18s %-14s %-20s %-10s %s\n", "Session", "Machine", "Model", "Status", "Task")
|
||||
fmt.Println(strings.Repeat("-", 90))
|
||||
for _, a := range agents {
|
||||
task := strings.ReplaceAll(a.CurrentTask, "\n", " ")
|
||||
if len(task) > 35 {
|
||||
task = task[:33] + ".."
|
||||
}
|
||||
fmt.Printf("%-18s %-14s %-20s %-10s %s\n",
|
||||
a.Session, a.MachineShort(), a.ShortModel(), statusStr(a), task)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry point ──────────────────────────────────────────────────
|
||||
|
||||
func main() {
|
||||
// Set local hostname
|
||||
var err error
|
||||
localHostname, err = os.Hostname()
|
||||
if err != nil {
|
||||
localHostname = "localhost"
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
// Simple flag parsing (before TUI init)
|
||||
args := os.Args[1:]
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "-h", "--help":
|
||||
fmt.Println(`Pi Dashboard — real-time status for pi coding agents
|
||||
|
||||
Usage:
|
||||
pi-dashboard TUI mode (default)
|
||||
pi-dashboard --list Print agent states as text
|
||||
pi-dashboard --help Show this help
|
||||
|
||||
Keybindings (TUI mode):
|
||||
↑↓ Navigate agents
|
||||
Enter Attach to agent (tmux attach)
|
||||
d Open hunk diff
|
||||
Tab Open tuxedo task list
|
||||
r Force refresh
|
||||
q Quit
|
||||
|
||||
Config: ~/.config/pi-dashboard.yaml
|
||||
poll_interval: 2 # seconds
|
||||
local:
|
||||
path: ~/.pi/agent/dashboard/
|
||||
remote:
|
||||
- host: 192.168.20.13
|
||||
user: sam
|
||||
path: /home/sam/.pi/agent/dashboard/
|
||||
`)
|
||||
return
|
||||
case "--list":
|
||||
cfg := defaultConfig()
|
||||
agents, err := readLocalStates(cfg.Local.Path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
printAgentList(agents)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
cfgPath := home + "/.config/pi-dashboard.yaml"
|
||||
cfg, err := loadConfig(cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Warning: config error: %v\n", err)
|
||||
cfg = defaultConfig()
|
||||
}
|
||||
|
||||
p := tea.NewProgram(initialModel(cfg), tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user