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:
56
config.go
Normal file
56
config.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
30
go.mod
Normal file
30
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
66
go.sum
Normal file
66
go.sum
Normal file
@@ -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=
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
173
state.go
Normal file
173
state.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user