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