TUI: async polling off UI thread, 5s SSH hard timeout + remote cooldown, at-most-one-fetch, ? help menu
This commit is contained in:
143
main.go
143
main.go
@@ -65,22 +65,30 @@ var sortNames = map[int]string{
|
||||
|
||||
// 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
|
||||
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
|
||||
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
|
||||
windowWidth int
|
||||
}
|
||||
|
||||
type pollMsg struct{}
|
||||
|
||||
type pollResultMsg struct {
|
||||
agents []AgentState
|
||||
err error
|
||||
}
|
||||
|
||||
func initialModel(cfg Config) model {
|
||||
columns := []table.Column{
|
||||
{Title: "Type", Width: 5},
|
||||
@@ -112,13 +120,14 @@ func initialModel(cfg Config) model {
|
||||
t.SetStyles(s)
|
||||
|
||||
return model{
|
||||
table: t,
|
||||
help: help.New(),
|
||||
agents: []AgentState{},
|
||||
config: cfg,
|
||||
hideClosed: cfg.HideClosed,
|
||||
hideSubagents: false,
|
||||
sortMode: sortSmart,
|
||||
table: t,
|
||||
help: help.New(),
|
||||
agents: []AgentState{},
|
||||
config: cfg,
|
||||
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) {
|
||||
var all []AgentState
|
||||
// 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)
|
||||
}
|
||||
all = append(all, local...)
|
||||
|
||||
// Remote
|
||||
for _, r := range m.config.Remote {
|
||||
remote, err := readRemoteStates(r)
|
||||
// Local
|
||||
local, err := readLocalStates(m.config.Local.Path)
|
||||
if err != nil {
|
||||
// Skip unreachable remotes silently
|
||||
continue
|
||||
return pollResultMsg{err: fmt.Errorf("local: %w", err)}
|
||||
}
|
||||
all = append(all, remote...)
|
||||
}
|
||||
all = append(all, local...)
|
||||
|
||||
return all, nil
|
||||
// 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 {
|
||||
m.remoteCooldown[r.Host] = time.Now().Add(30 * time.Second)
|
||||
continue
|
||||
}
|
||||
all = append(all, remote...)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
17
state.go
17
state.go
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user