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:
604
main.go
604
main.go
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user