commit 112698cada2bc8066c6415f098455978b2116e8b Author: Sam Rolfe Date: Fri Jul 31 14:25:37 2026 +1000 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 diff --git a/config.go b/config.go new file mode 100644 index 0000000..9e93d82 --- /dev/null +++ b/config.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// Config holds the dashboard configuration +type Config struct { + PollInterval int `yaml:"poll_interval"` // in seconds + Local struct { + Path string `yaml:"path"` + } `yaml:"local"` + Remote []RemoteConfig `yaml:"remote"` +} + +// RemoteConfig holds SSH connection info for a remote machine +type RemoteConfig struct { + Host string `yaml:"host"` + User string `yaml:"user"` + Path string `yaml:"path"` + Port int `yaml:"port"` +} + +func defaultConfig() Config { + home, _ := os.UserHomeDir() + return Config{ + PollInterval: 2, + Local: struct { + Path string `yaml:"path"` + }{ + Path: filepath.Join(home, ".pi", "agent", "dashboard"), + }, + } +} + +func loadConfig(path string) (Config, error) { + cfg := defaultConfig() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil // use defaults + } + return cfg, fmt.Errorf("reading config: %w", err) + } + + if err := yaml.Unmarshal(data, &cfg); err != nil { + return cfg, fmt.Errorf("parsing config: %w", err) + } + + return cfg, nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2343c26 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module pi-dashboard + +go 1.26.2 + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..aadba02 --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..4676b26 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/state.go b/state.go new file mode 100644 index 0000000..280592a --- /dev/null +++ b/state.go @@ -0,0 +1,173 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// AgentState matches the TypeScript interface from dashboard.ts +type AgentState struct { + Session string `json:"session"` + Machine string `json:"machine"` + AgentType string `json:"agent_type"` + Model string `json:"model"` + Status string `json:"status"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + DurationSeconds int `json:"duration_seconds"` + ToolCalls int `json:"tool_calls"` + CurrentTask string `json:"current_task"` + LastTool *string `json:"last_tool"` + LastToolAt *string `json:"last_tool_at"` + ConnectionType string `json:"connection_type"` + ConnectionName string `json:"connection_name"` + Blocked bool `json:"blocked"` + BlockedPrompt *string `json:"blocked_prompt"` + CostEstimate float64 `json:"cost_estimate"` +} + +func (a AgentState) StatusSymbol() string { + switch a.Status { + case "running": + return "●" + case "completed": + return "✓" + case "error": + return "✕" + case "blocked": + return "⚠" + default: + return "○" + } +} + +func (a AgentState) DurationStr() string { + d := a.DurationSeconds + if d == 0 { + return "" + } + if d < 60 { + return fmt.Sprintf("%ds", d) + } + if d < 3600 { + return fmt.Sprintf("%dm%ds", d/60, d%60) + } + return fmt.Sprintf("%dh%dm", d/3600, (d%3600)/60) +} + +func (a AgentState) ShortModel() string { + parts := strings.Split(a.Model, "/") + if len(parts) == 2 { + s := parts[1] + if len(s) > 16 { + s = s[:14] + ".." + } + return s + } + if len(a.Model) > 16 { + return a.Model[:14] + ".." + } + return a.Model +} + +func (a AgentState) MachineShort() string { + s := a.Machine + if len(s) > 12 { + s = s[:10] + ".." + } + return s +} + +// IsLocal returns true if this agent is on the same machine +func (a AgentState) IsLocal() bool { + return a.Machine == localHostname +} + +var localHostname string + +// readLocalStates reads all JSON files from the given directory +func readLocalStates(dir string) ([]AgentState, error) { + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading dir %s: %w", dir, err) + } + + var states []AgentState + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + s, err := readStateFile(filepath.Join(dir, entry.Name())) + if err == nil { + states = append(states, s) + } + } + return states, nil +} + +func readStateFile(path string) (AgentState, error) { + data, err := os.ReadFile(path) + if err != nil { + return AgentState{}, err + } + var s AgentState + if err := json.Unmarshal(data, &s); err != nil { + return AgentState{}, err + } + return s, nil +} + +// readRemoteStates SSHes into a machine and reads state files +func readRemoteStates(cfg RemoteConfig) ([]AgentState, error) { + hostPort := cfg.Host + if cfg.Port > 0 && cfg.Port != 22 { + hostPort = fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + } + + user := cfg.User + if user == "" { + user = "sam" + } + + remotePath := cfg.Path + if remotePath == "" { + home, _ := os.UserHomeDir() + remotePath = filepath.Join(home, ".pi", "agent", "dashboard") + } + + // SSH in, list JSON files, cat each one + cmd := exec.Command("ssh", + "-o", "ConnectTimeout=5", + "-o", "BatchMode=yes", + fmt.Sprintf("%s@%s", user, hostPort), + fmt.Sprintf("cat %s/*.json 2>/dev/null || true", remotePath), + ) + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("ssh %s: %w", cfg.Host, err) + } + + // Multiple JSON objects concatenated — split by newline boundaries + // and parse each line individually + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + var states []AgentState + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var s AgentState + if err := json.Unmarshal([]byte(line), &s); err != nil { + continue // skip malformed lines + } + states = append(states, s) + } + return states, nil +}