Files
pi-dashboard/main.go

845 lines
21 KiB
Go

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 <cwd>/.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)
}
}