diff --git a/main.go b/main.go index b2e229c..3aaa7c9 100644 --- a/main.go +++ b/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 (/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 'pi -p \"your prompt\"'", + " ssh 'tmux new -ds \"pi -p \\\"your prompt\\\"\"' # runs while laptop is closed", + "Watch them here: Ⓣ rows attach with Enter. Close: tmux kill-session -t ", + } + 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 diff --git a/state.go b/state.go index 7095d63..e6b66f0 100644 --- a/state.go +++ b/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) }