TUI: folder/started/closed columns, heartbeat staleness, hide-closed (x), hide-sub (z), sort (s), detail pane, hunk in agent cwd, tuxedo project todo, zellij/tmux attach, fix remote JSON parsing

This commit is contained in:
2026-08-04 11:04:41 +10:00
parent 112698cada
commit f0571465b2
3 changed files with 666 additions and 173 deletions

604
main.go
View File

@@ -4,6 +4,8 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
@@ -22,10 +24,20 @@ var (
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")
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")).
@@ -36,29 +48,48 @@ var (
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
config Config
lastPoll time.Time
err error
ready bool
windowWidth int
table table.Model
help help.Model
agents []AgentState // filtered + sorted view
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: "Machine", Width: 12},
{Title: "Type", Width: 5},
{Title: "Session", Width: 16},
{Title: "Folder", Width: 16},
{Title: "Status", Width: 10},
{Title: "Model", Width: 18},
{Title: "Duration", Width: 10},
{Title: "Tools", Width: 6},
{Title: "Task", Width: 30},
{Title: "Model", Width: 14},
{Title: "Started", Width: 10},
{Title: "Tools", Width: 5},
{Title: "Task", Width: 24},
}
t := table.New(
@@ -80,13 +111,24 @@ func initialModel(cfg Config) model {
t.SetStyles(s)
return model{
table: t,
help: help.New(),
agents: []AgentState{},
config: cfg,
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
@@ -135,6 +177,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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:
@@ -167,13 +221,28 @@ func (m model) View() string {
var b strings.Builder
// Header
summary := fmt.Sprintf("%d agent(s)", len(m.agents))
live, closed := 0, 0
for _, a := range m.agents {
if a.IsClosed(m.staleAfter()) {
closed++
} else {
live++
}
}
summary := fmt.Sprintf("%d agent(s) · %d live · %d closed", 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 │ %s ╞",
"╡ Pi Dashboard │ %s │ sort: %s │ %s ╞",
summary,
sortNames[m.sortMode],
time.Now().Format("15:04:05"),
)))
b.WriteString("\n\n")
@@ -189,34 +258,154 @@ func (m model) View() string {
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(
"↑↓ navigate · Enter attach · d hunk · Tab tuxedo · r refresh · q quit",
"↑↓ 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.agents) == 0 {
return ""
}
row := m.table.Cursor()
if row < 0 || row >= len(m.agents) {
return ""
}
a := m.agents[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() {
var rows []table.Row
// Filter
var view []AgentState
for _, a := range m.agents {
status := statusStr(a)
if m.hideClosed && a.IsClosed(m.staleAfter()) {
continue
}
if m.hideSubagents && a.IsSubagent {
continue
}
view = append(view, a)
}
// 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) > 30 {
task = task[:28] + ".."
if len(task) > 24 {
task = task[:22] + ".."
}
rows = append(rows, table.Row{
a.MachineShort(),
a.Session,
status,
termSymbol(a.TerminalType, a.ConnectionType),
trunc(session, 16),
trunc(a.Folder(), 16),
a.StatusStr(m.staleAfter()),
a.ShortModel(),
a.DurationStr(),
fmtTime(a.SessionStartedAt),
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 {
@@ -228,6 +417,74 @@ func (m *model) updateTable() {
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 {
@@ -244,6 +501,11 @@ func pollNow() tea.Cmd {
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.agents) == 0 {
return nil
@@ -253,74 +515,52 @@ func attachToAgent(m model) tea.Cmd {
return nil
}
a := m.agents[row]
name := a.ConnectionName
if name == "" {
name = a.Session
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("tmux", "attach-session", "-t", name),
exec.Command(bin, args...),
func(err error) tea.Msg {
if err != nil { return errMsg{err} }
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 {
// 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
}
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)
}
remoteCmd := strings.Join(append([]string{bin}, args...), " ")
return tea.ExecProcess(
exec.Command("tuxedo", todoFile),
exec.Command("ssh", "-t", fmt.Sprintf("%s@%s", sshUser, sshHost), remoteCmd),
func(err error) tea.Msg {
if err != nil {
return errMsg{err}
@@ -330,39 +570,161 @@ func openTuxedo(m model) tea.Cmd {
)
}
// 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.agents) == 0 {
return nil
}
row := m.table.Cursor()
if row < 0 || row >= len(m.agents) {
return nil
}
a := m.agents[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.agents) > 0 {
row := m.table.Cursor()
if row >= 0 && row < len(m.agents) {
agent = m.agents[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
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")),
Tuxedo: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "tuxedo tasks")),
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 {
switch a.Status {
case "running":
return "● running"
case "completed":
return "✓ done"
case "error":
return "✕ error"
case "blocked":
return "⚠ blocked"
default:
return "○ idle"
}
// Uses a 45s stale window for the plain-text list mode
return a.StatusStr(45 * time.Second)
}
func printAgentList(agents []AgentState) {
@@ -370,16 +732,23 @@ func printAgentList(agents []AgentState) {
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))
fmt.Printf("%-6s %-18s %-16s %-10s %-10s %s\n", "Type", "Session", "Folder", "Status", "Started", "Task")
fmt.Println(strings.Repeat("-", 100))
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)
fmt.Printf("%-6s %-18s %-16s %-10s %-10s %s\n",
termSymbol(a.TerminalType, a.ConnectionType),
trunc(a.Session, 18),
trunc(a.Folder(), 16),
statusStr(a),
fmtTime(a.SessionStartedAt),
task)
}
fmt.Println(strings.Repeat("-", 100))
fmt.Println("Type: Ⓣ tmux · Ⓩ zellij · ⓉⓏ both · · direct")
}
// ── Entry point ──────────────────────────────────────────────────
@@ -408,14 +777,19 @@ Usage:
Keybindings (TUI mode):
↑↓ Navigate agents
Enter Attach to agent (tmux attach)
d Open hunk diff
Tab Open tuxedo task list
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
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: