package main import ( "fmt" "os" "os/exec" "path/filepath" "sort" "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") statusIdle = lipgloss.NewStyle().Foreground(lipgloss.Color("#888888")).SetString("○ idle") statusBlocked = lipgloss.NewStyle().Foreground(lipgloss.Color("#ffaa00")).SetString("⚠ blocked") statusError = lipgloss.NewStyle().Foreground(lipgloss.Color("#ff4444")).SetString("✕ error") statusClosed = lipgloss.NewStyle().Foreground(lipgloss.Color("#555555")).SetString("✗ closed") statusLost = lipgloss.NewStyle().Foreground(lipgloss.Color("#773333")).SetString("✗ lost") subagentMark = lipgloss.NewStyle().Foreground(lipgloss.Color("#55aaff")).SetString("◇") detailKeyStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#00d4ff")).Bold(true) detailLabelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#aaaaaa")) detailValStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#dddddd")) detailBoxStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("240")). Padding(0, 1) infoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#aaaaaa")). Padding(0, 1) footerStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#888888")). Padding(0, 1) ) // Sort modes const ( sortSmart = iota // live first, running first, then last activity sortActivity // most recent activity first sortStarted // most recently started first sortFolder // folder name A-Z ) var sortNames = map[int]string{ sortSmart: "smart", sortActivity: "activity", sortStarted: "started", sortFolder: "folder", } // Model type model struct { table table.Model help help.Model agents []AgentState // all agents from the last poll (unfiltered) view []AgentState // filtered + sorted view backing the table config Config hideClosed bool hideSubagents bool sortMode int lastPoll time.Time err error ready bool windowWidth int } type pollMsg struct{} func initialModel(cfg Config) model { columns := []table.Column{ {Title: "Type", Width: 5}, {Title: "Session", Width: 16}, {Title: "Folder", Width: 16}, {Title: "Status", Width: 10}, {Title: "Started", Width: 10}, {Title: "Closed", Width: 10}, {Title: "Tools", Width: 5}, {Title: "Task", Width: 20}, } 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, hideClosed: cfg.HideClosed, hideSubagents: false, sortMode: sortSmart, } } func (m model) staleAfter() time.Duration { s := m.config.StaleAfter if s <= 0 { s = 45 } return time.Duration(s) * time.Second } 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 key.Matches(msg, keys.Sort): m.sortMode = (m.sortMode + 1) % len(sortNames) m.updateTable() return m, nil case key.Matches(msg, keys.HideClosed): m.hideClosed = !m.hideClosed m.updateTable() return m, nil case key.Matches(msg, keys.HideSubagents): m.hideSubagents = !m.hideSubagents m.updateTable() return m, nil } 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 live, closed := 0, 0 for _, a := range m.agents { if a.IsClosed(m.staleAfter()) { closed++ } else { live++ } } summary := fmt.Sprintf("%d shown of %d agents · %d live · %d closed", len(m.view), len(m.agents), live, closed) if m.hideClosed { summary += " │ closed hidden" } if m.hideSubagents { summary += " │ subagents hidden" } 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 │ sort: %s │ %s ╞", summary, sortNames[m.sortMode], 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") // Detail pane for the selected row if d := m.detailView(); d != "" { b.WriteString(detailBoxStyle.Render(d)) b.WriteString("\n") } // Keybindings help b.WriteString(footerStyle.Render( "↑↓ select · Enter attach · d hunk · Tab tuxedo · s sort · x hide closed · z hide sub · r refresh · q quit", )) return b.String() } func (m model) detailView() string { if len(m.view) == 0 { return "" } row := m.table.Cursor() if row < 0 || row >= len(m.view) { return "" } a := m.view[row] stale := m.staleAfter() term := a.TerminalType if term == "" { term = a.ConnectionType } if term == "" { term = "direct" } var termExtra string switch { case a.ZellijSession != "" && a.TmuxSession != "": termExtra = fmt.Sprintf("zellij:%s tmux:%s", a.ZellijSession, a.TmuxSession) case a.ZellijSession != "": termExtra = fmt.Sprintf("zellij:%s", a.ZellijSession) case a.TmuxSession != "": termExtra = fmt.Sprintf("tmux:%s", a.TmuxSession) } if termExtra != "" { term += " (" + termExtra + ")" } if a.PID > 0 { term += fmt.Sprintf(" pid:%d", a.PID) } model := a.Model if model == "" { model = "unknown" } closedInfo := "—" if a.IsClosed(stale) { if a.ClosedAt != nil && *a.ClosedAt != "" { closedInfo = fmtTime(a.ClosedAt) + " (graceful)" } else { closedInfo = fmtTime(a.LastSeenAt) + " (last seen)" } } var lines []string lines = append(lines, fmt.Sprintf("%s %s %s %s", detailKeyStyle.Render(a.Session), detailLabelStyle.Render(a.Machine), a.StatusStr(stale), detailLabelStyle.Render(model), )) lines = append(lines, fmt.Sprintf("%s %s", detailLabelStyle.Render("Folder:"), detailValStyle.Render(a.Cwd))) lines = append(lines, fmt.Sprintf("%s %s", detailLabelStyle.Render("Terminal:"), detailValStyle.Render(term))) lines = append(lines, fmt.Sprintf("%s %s", detailLabelStyle.Render("Started:"), detailValStyle.Render(fmtTime(a.SessionStartedAt)))) lines = append(lines, fmt.Sprintf("%s %s", detailLabelStyle.Render("Closed:"), detailValStyle.Render(closedInfo))) lines = append(lines, fmt.Sprintf("%s %s %s %s", detailLabelStyle.Render("Tools:"), detailValStyle.Render(fmt.Sprintf("%d", a.ToolCalls)), detailLabelStyle.Render("Duration:"), detailValStyle.Render(a.DurationStr()))) if a.LastTool != nil && a.LastToolAt != nil { lines = append(lines, fmt.Sprintf("%s %s %s", detailLabelStyle.Render("Last tool:"), detailValStyle.Render(*a.LastTool), detailLabelStyle.Render("at "+fmtTime(a.LastToolAt)))) } if a.CurrentTask != "" { lines = append(lines, fmt.Sprintf("%s %s", detailLabelStyle.Render("Task:"), detailValStyle.Render(a.CurrentTask))) } if a.Blocked { lines = append(lines, fmt.Sprintf("%s %s", detailLabelStyle.Render("Blocked:"), detailValStyle.Render(strval(a.BlockedPrompt)))) } return strings.Join(lines, "\n") } func strval(s *string) string { if s == nil { return "" } return *s } func (m *model) updateTable() { // Filter var view []AgentState for _, a := range m.agents { if m.hideClosed && a.IsClosed(m.staleAfter()) { continue } if m.hideSubagents && a.IsSubagent { continue } view = append(view, a) } m.view = view // Sort sortAgents(view, m.sortMode, m.staleAfter()) // Render rows var rows []table.Row for _, a := range view { session := a.Session if a.IsSubagent { session = "◇ " + session } task := strings.ReplaceAll(a.CurrentTask, "\n", " ") if len(task) > 20 { task = task[:18] + ".." } var closedTime string if a.IsClosed(m.staleAfter()) { if a.ClosedAt != nil && *a.ClosedAt != "" { closedTime = fmtTime(a.ClosedAt) } else { closedTime = fmtTime(a.LastSeenAt) // approx: last seen } } rows = append(rows, table.Row{ termSymbol(a.TerminalType, a.ConnectionType), trunc(session, 16), trunc(a.Folder(), 16), a.StatusStr(m.staleAfter()), fmtTime(a.SessionStartedAt), closedTime, fmt.Sprintf("%d", a.ToolCalls), task, }) } m.table.SetRows(rows) // Keep cursor in range if m.table.Cursor() > len(rows)-1 && len(rows) > 0 { m.table.SetCursor(len(rows) - 1) } // Auto-height h := len(rows) + 2 if h < 5 { h = 5 } if h > 30 { h = 30 } m.table.SetHeight(h) } func trunc(s string, n int) string { if len(s) <= n { return s } if n <= 2 { return s[:n] } return s[:n-2] + ".." } func termSymbol(termType, connType string) string { t := termType if t == "" { t = connType } switch { case strings.Contains(t, "tmux") && strings.Contains(t, "zellij"): return "ⓉⓏ" case strings.Contains(t, "tmux"): return "Ⓣ" case strings.Contains(t, "zellij"): return "Ⓩ" default: return "·" } } // statusRank orders live sessions: running < blocked < error < idle func statusRank(a AgentState) int { switch a.agentStatus() { case "running": return 0 case "blocked": return 1 case "error": return 2 default: return 3 } } func sortAgents(agents []AgentState, mode int, stale time.Duration) { sort.SliceStable(agents, func(i, j int) bool { a, b := agents[i], agents[j] ac, bc := a.IsClosed(stale), b.IsClosed(stale) switch mode { case sortSmart: if ac != bc { return !ac // live sessions first } if !ac { ra, rb := statusRank(a), statusRank(b) if ra != rb { return ra < rb } } return a.lastActivity().After(b.lastActivity()) case sortActivity: return a.lastActivity().After(b.lastActivity()) case sortStarted: return a.sessionStart().After(b.sessionStart()) case sortFolder: return strings.ToLower(a.Folder()) < strings.ToLower(b.Folder()) } return false }) } // ── 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 } // attachToAgent attaches to the selected agent's terminal session: // - tmux session → tmux attach (local) / ssh tmux attach (remote) // - zellij session → zellij attach from outside, or // `zellij action switch-session` when the dashboard itself runs inside zellij // - direct → no attach target; show an error func attachToAgent(m model) tea.Cmd { if len(m.view) == 0 { return nil } row := m.table.Cursor() if row < 0 || row >= len(m.view) { return nil } a := m.view[row] term := a.TerminalType if term == "" { term = a.ConnectionType } var bin string var args []string switch { case strings.Contains(term, "tmux") && a.TmuxSession != "": bin, args = "tmux", []string{"attach-session", "-t", a.TmuxSession} case strings.Contains(term, "zellij") && a.ZellijSession != "": bin = "zellij" if os.Getenv("ZELLIJ_SESSION_NAME") != "" { // Dashboard is running inside zellij — switch instead of attach args = []string{"action", "switch-session", a.ZellijSession} } else { args = []string{"attach", a.ZellijSession} } default: m.err = fmt.Errorf("no attach target for %s (terminal: %s)", a.Session, term) return nil } if a.IsLocal() { return tea.ExecProcess( exec.Command(bin, args...), func(err error) tea.Msg { if err != nil { return errMsg{err} } return nil }, ) } // Remote agent: run the attach command over SSH sshHost, sshUser := findRemote(m, a) if sshHost == "" { m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) return nil } remoteCmd := strings.Join(append([]string{bin}, args...), " ") return tea.ExecProcess( exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), func(err error) tea.Msg { if err != nil { return errMsg{err} } return nil }, ) } // openHunk reviews the selected agent's changes with Hunk, in the folder // the agent was actually running in. func openHunk(m model) tea.Cmd { if len(m.view) == 0 { return nil } row := m.table.Cursor() if row < 0 || row >= len(m.view) { return nil } a := m.view[row] if a.Cwd == "" { m.err = fmt.Errorf("no folder recorded for %s", a.Session) return nil } if a.IsLocal() { cmd := exec.Command("hunk", "diff", "--watch") cmd.Dir = a.Cwd return tea.ExecProcess( cmd, func(err error) tea.Msg { if err != nil { return errMsg{err} } return nil }, ) } // Remote: run hunk over SSH in the agent's folder sshHost, sshUser := findRemote(m, a) if sshHost == "" { m.err = fmt.Errorf("no SSH config for remote machine: %s", a.Machine) return nil } remoteCmd := fmt.Sprintf("cd %s && hunk diff --watch", a.Cwd) return tea.ExecProcess( exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), func(err error) tea.Msg { if err != nil { return errMsg{err} } return nil }, ) } // openTuxedo opens the selected agent's project task board (todo.txt), // which lives at /.pi/dashboard/tasks/todo.txt. func openTuxedo(m model) tea.Cmd { home, _ := os.UserHomeDir() globalTodo := home + "/.pi/agent/dashboard/tasks/todo.txt" var todoPath string var agent AgentState haveAgent := false if len(m.view) > 0 { row := m.table.Cursor() if row >= 0 && row < len(m.view) { agent = m.view[row] haveAgent = true if agent.Cwd != "" { todoPath = agent.Cwd + "/.pi/dashboard/tasks/todo.txt" } } } if todoPath == "" { todoPath = globalTodo } ensureTodo := func(path string) { os.MkdirAll(filepath.Dir(path), 0755) if _, err := os.Stat(path); os.IsNotExist(err) { os.WriteFile(path, []byte(""), 0644) } } if !haveAgent || agent.IsLocal() { ensureTodo(todoPath) return tea.ExecProcess( exec.Command("tuxedo", todoPath), func(err error) tea.Msg { if err != nil { return errMsg{err} } return nil }, ) } // Remote: run tuxedo over SSH on the agent's project todo sshHost, sshUser := findRemote(m, agent) if sshHost == "" { m.err = fmt.Errorf("no SSH config for remote machine: %s", agent.Machine) return nil } remoteCmd := fmt.Sprintf("mkdir -p %s && touch %s && tuxedo %s", filepath.Dir(todoPath), todoPath, todoPath) return tea.ExecProcess( exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd), func(err error) tea.Msg { if err != nil { return errMsg{err} } return nil }, ) } // findRemote returns the (host, user) for the SSH config matching a machine, // or ("", "") if none matches. func findRemote(m model, a AgentState) (string, string) { for _, r := range m.config.Remote { if strings.Contains(a.Machine, r.Host) || strings.Contains(r.Host, a.Machine) { user := r.User if user == "" { user = "sam" } return r.Host, user } } return "", "" } // ── Keybindings ────────────────────────────────────────────────── type keyMap struct { Quit key.Binding Refresh key.Binding Attach key.Binding Hunk key.Binding Tuxedo key.Binding Sort key.Binding HideClosed key.Binding HideSubagents 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 in agent folder")), Tuxedo: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "tuxedo project tasks")), Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), HideClosed: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hide closed")), HideSubagents: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "hide subagents")), } // ── Helpers ────────────────────────────────────────────────────── func statusStr(a AgentState) string { // Uses a 45s stale window for the plain-text list mode return a.StatusStr(45 * time.Second) } func printAgentList(agents []AgentState) { if len(agents) == 0 { fmt.Println("No agents found in ~/.pi/agent/dashboard/") return } stale := 45 * time.Second fmt.Printf("%-6s %-18s %-16s %-10s %-10s %-10s %s\n", "Type", "Session", "Folder", "Status", "Started", "Closed", "Task") fmt.Println(strings.Repeat("-", 110)) for _, a := range agents { task := strings.ReplaceAll(a.CurrentTask, "\n", " ") if len(task) > 35 { task = task[:33] + ".." } var closedTime string if a.IsClosed(stale) { if a.ClosedAt != nil && *a.ClosedAt != "" { closedTime = fmtTime(a.ClosedAt) } else { closedTime = fmtTime(a.LastSeenAt) } } fmt.Printf("%-6s %-18s %-16s %-10s %-10s %-10s %s\n", termSymbol(a.TerminalType, a.ConnectionType), trunc(a.Session, 18), trunc(a.Folder(), 16), statusStr(a), fmtTime(a.SessionStartedAt), closedTime, task) } fmt.Println(strings.Repeat("-", 110)) fmt.Println("Type: Ⓣ tmux · Ⓩ zellij · ⓉⓏ both · · direct") } // ── 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 / zellij attach / switch) d Review agent's changes with Hunk (in the agent's folder) Tab Open agent's project task board in Tuxedo s Cycle sort: smart > activity > started > folder x Toggle hiding closed sessions z Toggle hiding sub-agent sessions r Force refresh q Quit Config: ~/.config/pi-dashboard.yaml poll_interval: 2 # seconds stale_after: 45 # seconds without heartbeat => session considered closed hide_closed: false # start with closed sessions hidden 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) } }