TUI: async polling off UI thread, 5s SSH hard timeout + remote cooldown, at-most-one-fetch, ? help menu

This commit is contained in:
2026-08-06 15:12:17 +10:00
parent 820f4a2f8b
commit b24f9012d6
2 changed files with 115 additions and 45 deletions

81
main.go
View File

@@ -73,6 +73,9 @@ type model struct {
hideClosed bool
hideSubagents bool
sortMode int
polling bool // a fetch is in flight (prevents concurrent polls)
remoteCooldown map[string]time.Time // per-remote failure cooldown
showHelp bool
lastPoll time.Time
err error
ready bool
@@ -81,6 +84,11 @@ type model struct {
type pollMsg struct{}
type pollResultMsg struct {
agents []AgentState
err error
}
func initialModel(cfg Config) model {
columns := []table.Column{
{Title: "Type", Width: 5},
@@ -119,6 +127,7 @@ func initialModel(cfg Config) model {
hideClosed: cfg.HideClosed,
hideSubagents: false,
sortMode: sortSmart,
remoteCooldown: map[string]time.Time{},
}
}
@@ -130,27 +139,36 @@ func (m model) staleAfter() time.Duration {
return time.Duration(s) * time.Second
}
func (m model) fetchAllAgents() ([]AgentState, error) {
// pollCmd fetches all state files off the UI thread (bubbletea runs Cmds in
// goroutines). Remote SSH calls get a hard timeout inside readRemoteStates and
// a failure cooldown here, so a slow or unreachable remote never blocks the
// UI or accumulates hung ssh processes.
func (m model) pollCmd() tea.Cmd {
return func() tea.Msg {
var all []AgentState
// Local
local, err := readLocalStates(m.config.Local.Path)
if err != nil {
return nil, fmt.Errorf("local: %w", err)
return pollResultMsg{err: fmt.Errorf("local: %w", err)}
}
all = append(all, local...)
// Remote
// Remote (with per-host failure cooldown)
for _, r := range m.config.Remote {
if m.remoteCooldown[r.Host].After(time.Now()) {
continue
}
remote, err := readRemoteStates(r)
if err != nil {
// Skip unreachable remotes silently
m.remoteCooldown[r.Host] = time.Now().Add(30 * time.Second)
continue
}
all = append(all, remote...)
}
return all, nil
return pollResultMsg{agents: all}
}
}
func (m model) Init() tea.Cmd {
@@ -190,19 +208,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.hideSubagents = !m.hideSubagents
m.updateTable()
return m, nil
case key.Matches(msg, keys.Help):
m.showHelp = !m.showHelp
return m, nil
}
case pollMsg:
agents, err := m.fetchAllAgents()
if err != nil {
m.err = err
// At most one fetch in flight; keep the 2s tick alive regardless
if m.polling {
return m, tick()
}
m.polling = true
return m, tea.Batch(m.pollCmd(), tick())
case pollResultMsg:
m.polling = false
if msg.err != nil {
m.err = msg.err
} else {
m.err = nil
m.agents = agents
m.agents = msg.agents
m.lastPoll = time.Now()
m.updateTable()
}
return m, tick()
return m, nil
case errMsg:
m.err = msg.err
@@ -265,14 +294,41 @@ func (m model) View() string {
b.WriteString("\n")
}
// Help panel
if m.showHelp {
b.WriteString(detailBoxStyle.Render(m.helpText()))
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",
"↑↓ select · Enter attach · d hunk · Tab tuxedo · s sort · x hide closed · z hide sub · r refresh · ? help · q quit",
))
return b.String()
}
func (m model) helpText() string {
lines := []string{
"↑↓ / j k select agent",
"Enter attach: tmux attach / zellij attach / switch-session (local or SSH)",
"d Hunk diff review in the agent's folder (ssh for remote)",
"Tab Tuxedo task board for the agent's project (<cwd>/todo.txt)",
"s cycle sort: smart → activity → started → folder",
"x toggle hiding closed sessions",
"z toggle hiding sub-agent rows",
"r force refresh",
"? toggle this help",
"q / Ctrl+C quit",
"",
"Background agents (long-running, survive terminal close):",
" tmux new -ds <name> 'pi -p \"your prompt\"'",
" ssh <server> 'tmux new -ds <name> \"pi -p \\\"your prompt\\\"\"' # runs while laptop is closed",
"Watch them here: Ⓣ rows attach with Enter. Close: tmux kill-session -t <name>",
}
return strings.Join(lines, "\n")
}
func (m model) detailView() string {
if len(m.view) == 0 {
return ""
@@ -717,6 +773,7 @@ type keyMap struct {
Sort key.Binding
HideClosed key.Binding
HideSubagents key.Binding
Help key.Binding
}
var keys = keyMap{
@@ -728,6 +785,7 @@ var keys = keyMap{
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")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
}
// ── Helpers ──────────────────────────────────────────────────────
@@ -803,6 +861,7 @@ Keybindings (TUI mode):
s Cycle sort: smart > activity > started > folder
x Toggle hiding closed sessions
z Toggle hiding sub-agent sessions
? Toggle in-TUI help
r Force refresh
q Quit

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -275,15 +276,25 @@ func readRemoteStates(cfg RemoteConfig) ([]AgentState, error) {
remotePath = filepath.Join(home, ".pi", "agent", "dashboard")
}
// SSH in, cat all JSON files
cmd := exec.Command("ssh",
"-o", "ConnectTimeout=5",
// SSH in, cat all JSON files. Hard 5s overall timeout (CommandContext)
// plus a 3s connect timeout — a slow or hung remote must never block the
// dashboard or leave ssh processes accumulating.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "ssh",
"-o", "ConnectTimeout=3",
"-o", "BatchMode=yes",
"-o", "ServerAliveInterval=5",
"-o", "ServerAliveCountMax=1",
fmt.Sprintf("%s@%s", user, hostPort),
fmt.Sprintf("cat %s/*.json 2>/dev/null || true", remotePath),
)
output, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("ssh %s: timeout after 5s", cfg.Host)
}
return nil, fmt.Errorf("ssh %s: %w", cfg.Host, err)
}