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
|
// Model
|
||||||
type model struct {
|
type model struct {
|
||||||
table table.Model
|
table table.Model
|
||||||
help help.Model
|
help help.Model
|
||||||
agents []AgentState // all agents from the last poll (unfiltered)
|
agents []AgentState // all agents from the last poll (unfiltered)
|
||||||
view []AgentState // filtered + sorted view backing the table
|
view []AgentState // filtered + sorted view backing the table
|
||||||
config Config
|
config Config
|
||||||
hideClosed bool
|
hideClosed bool
|
||||||
hideSubagents bool
|
hideSubagents bool
|
||||||
sortMode int
|
sortMode int
|
||||||
lastPoll time.Time
|
polling bool // a fetch is in flight (prevents concurrent polls)
|
||||||
err error
|
remoteCooldown map[string]time.Time // per-remote failure cooldown
|
||||||
ready bool
|
showHelp bool
|
||||||
windowWidth int
|
lastPoll time.Time
|
||||||
|
err error
|
||||||
|
ready bool
|
||||||
|
windowWidth int
|
||||||
}
|
}
|
||||||
|
|
||||||
type pollMsg struct{}
|
type pollMsg struct{}
|
||||||
|
|
||||||
|
type pollResultMsg struct {
|
||||||
|
agents []AgentState
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
func initialModel(cfg Config) model {
|
func initialModel(cfg Config) model {
|
||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Type", Width: 5},
|
{Title: "Type", Width: 5},
|
||||||
@@ -112,13 +120,14 @@ func initialModel(cfg Config) model {
|
|||||||
t.SetStyles(s)
|
t.SetStyles(s)
|
||||||
|
|
||||||
return model{
|
return model{
|
||||||
table: t,
|
table: t,
|
||||||
help: help.New(),
|
help: help.New(),
|
||||||
agents: []AgentState{},
|
agents: []AgentState{},
|
||||||
config: cfg,
|
config: cfg,
|
||||||
hideClosed: cfg.HideClosed,
|
hideClosed: cfg.HideClosed,
|
||||||
hideSubagents: false,
|
hideSubagents: false,
|
||||||
sortMode: sortSmart,
|
sortMode: sortSmart,
|
||||||
|
remoteCooldown: map[string]time.Time{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,27 +139,36 @@ func (m model) staleAfter() time.Duration {
|
|||||||
return time.Duration(s) * time.Second
|
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
|
||||||
var all []AgentState
|
// 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
|
||||||
local, err := readLocalStates(m.config.Local.Path)
|
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 {
|
if err != nil {
|
||||||
// Skip unreachable remotes silently
|
return pollResultMsg{err: fmt.Errorf("local: %w", err)}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
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 {
|
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.hideSubagents = !m.hideSubagents
|
||||||
m.updateTable()
|
m.updateTable()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
case key.Matches(msg, keys.Help):
|
||||||
|
m.showHelp = !m.showHelp
|
||||||
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
case pollMsg:
|
case pollMsg:
|
||||||
agents, err := m.fetchAllAgents()
|
// At most one fetch in flight; keep the 2s tick alive regardless
|
||||||
if err != nil {
|
if m.polling {
|
||||||
m.err = err
|
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 {
|
} else {
|
||||||
m.err = nil
|
m.err = nil
|
||||||
m.agents = agents
|
m.agents = msg.agents
|
||||||
m.lastPoll = time.Now()
|
m.lastPoll = time.Now()
|
||||||
m.updateTable()
|
m.updateTable()
|
||||||
}
|
}
|
||||||
return m, tick()
|
return m, nil
|
||||||
|
|
||||||
case errMsg:
|
case errMsg:
|
||||||
m.err = msg.err
|
m.err = msg.err
|
||||||
@@ -265,14 +294,41 @@ func (m model) View() string {
|
|||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Help panel
|
||||||
|
if m.showHelp {
|
||||||
|
b.WriteString(detailBoxStyle.Render(m.helpText()))
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Keybindings help
|
// Keybindings help
|
||||||
b.WriteString(footerStyle.Render(
|
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()
|
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 {
|
func (m model) detailView() string {
|
||||||
if len(m.view) == 0 {
|
if len(m.view) == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -717,6 +773,7 @@ type keyMap struct {
|
|||||||
Sort key.Binding
|
Sort key.Binding
|
||||||
HideClosed key.Binding
|
HideClosed key.Binding
|
||||||
HideSubagents key.Binding
|
HideSubagents key.Binding
|
||||||
|
Help key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
var keys = keyMap{
|
var keys = keyMap{
|
||||||
@@ -728,6 +785,7 @@ var keys = keyMap{
|
|||||||
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
Sort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")),
|
||||||
HideClosed: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hide closed")),
|
HideClosed: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "hide closed")),
|
||||||
HideSubagents: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "hide subagents")),
|
HideSubagents: key.NewBinding(key.WithKeys("z"), key.WithHelp("z", "hide subagents")),
|
||||||
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────
|
||||||
@@ -803,6 +861,7 @@ Keybindings (TUI mode):
|
|||||||
s Cycle sort: smart > activity > started > folder
|
s Cycle sort: smart > activity > started > folder
|
||||||
x Toggle hiding closed sessions
|
x Toggle hiding closed sessions
|
||||||
z Toggle hiding sub-agent sessions
|
z Toggle hiding sub-agent sessions
|
||||||
|
? Toggle in-TUI help
|
||||||
r Force refresh
|
r Force refresh
|
||||||
q Quit
|
q Quit
|
||||||
|
|
||||||
|
|||||||
17
state.go
17
state.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -275,15 +276,25 @@ func readRemoteStates(cfg RemoteConfig) ([]AgentState, error) {
|
|||||||
remotePath = filepath.Join(home, ".pi", "agent", "dashboard")
|
remotePath = filepath.Join(home, ".pi", "agent", "dashboard")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSH in, cat all JSON files
|
// SSH in, cat all JSON files. Hard 5s overall timeout (CommandContext)
|
||||||
cmd := exec.Command("ssh",
|
// plus a 3s connect timeout — a slow or hung remote must never block the
|
||||||
"-o", "ConnectTimeout=5",
|
// 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", "BatchMode=yes",
|
||||||
|
"-o", "ServerAliveInterval=5",
|
||||||
|
"-o", "ServerAliveCountMax=1",
|
||||||
fmt.Sprintf("%s@%s", user, hostPort),
|
fmt.Sprintf("%s@%s", user, hostPort),
|
||||||
fmt.Sprintf("cat %s/*.json 2>/dev/null || true", remotePath),
|
fmt.Sprintf("cat %s/*.json 2>/dev/null || true", remotePath),
|
||||||
)
|
)
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
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)
|
return nil, fmt.Errorf("ssh %s: %w", cfg.Host, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user