From a0d25aa108cfa6d3043a69e89c9cac6a81077371 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Tue, 4 Aug 2026 11:13:36 +1000 Subject: [PATCH] TUI: Closed column, legacy 30m stale window, filtered-view fix, header shown-count, toggle tests --- main.go | 66 ++++++++++++++++++++++++++++++----------------- main_test.go | 54 ++++++++++++++++++++++++++++++++++++++ state.go | 73 +++++++++++++++++++++++++++++++--------------------- 3 files changed, 141 insertions(+), 52 deletions(-) diff --git a/main.go b/main.go index 7826ebb..6bd1b66 100644 --- a/main.go +++ b/main.go @@ -67,7 +67,8 @@ var sortNames = map[int]string{ type model struct { table table.Model help help.Model - agents []AgentState // filtered + sorted view + agents []AgentState // all agents from the last poll (unfiltered) + view []AgentState // filtered + sorted view backing the table config Config hideClosed bool hideSubagents bool @@ -86,10 +87,10 @@ func initialModel(cfg Config) model { {Title: "Session", Width: 16}, {Title: "Folder", Width: 16}, {Title: "Status", Width: 10}, - {Title: "Model", Width: 14}, {Title: "Started", Width: 10}, + {Title: "Closed", Width: 10}, {Title: "Tools", Width: 5}, - {Title: "Task", Width: 24}, + {Title: "Task", Width: 20}, } t := table.New( @@ -229,7 +230,7 @@ func (m model) View() string { live++ } } - summary := fmt.Sprintf("%d agent(s) · %d live · %d closed", len(m.agents), live, closed) + summary := fmt.Sprintf("%d shown of %d agents · %d live · %d closed", len(m.view), len(m.agents), live, closed) if m.hideClosed { summary += " │ closed hidden" } @@ -273,14 +274,14 @@ func (m model) View() string { } func (m model) detailView() string { - if len(m.agents) == 0 { + if len(m.view) == 0 { return "" } row := m.table.Cursor() - if row < 0 || row >= len(m.agents) { + if row < 0 || row >= len(m.view) { return "" } - a := m.agents[row] + a := m.view[row] stale := m.staleAfter() term := a.TerminalType @@ -373,6 +374,7 @@ func (m *model) updateTable() { } view = append(view, a) } + m.view = view // Sort sortAgents(view, m.sortMode, m.staleAfter()) @@ -385,16 +387,24 @@ func (m *model) updateTable() { session = "◇ " + session } task := strings.ReplaceAll(a.CurrentTask, "\n", " ") - if len(task) > 24 { - task = task[:22] + ".." + if len(task) > 20 { + task = task[:18] + ".." + } + var closedTime string + if a.IsClosed(m.staleAfter()) { + if a.ClosedAt != nil && *a.ClosedAt != "" { + closedTime = fmtTime(a.ClosedAt) + } else { + closedTime = fmtTime(a.LastSeenAt) // approx: last seen + } } rows = append(rows, table.Row{ termSymbol(a.TerminalType, a.ConnectionType), trunc(session, 16), trunc(a.Folder(), 16), a.StatusStr(m.staleAfter()), - a.ShortModel(), fmtTime(a.SessionStartedAt), + closedTime, fmt.Sprintf("%d", a.ToolCalls), task, }) @@ -507,14 +517,14 @@ type errMsg struct{ err error } // `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 { + if len(m.view) == 0 { return nil } row := m.table.Cursor() - if row < 0 || row >= len(m.agents) { + if row < 0 || row >= len(m.view) { return nil } - a := m.agents[row] + a := m.view[row] term := a.TerminalType if term == "" { @@ -573,14 +583,14 @@ func attachToAgent(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 { + if len(m.view) == 0 { return nil } row := m.table.Cursor() - if row < 0 || row >= len(m.agents) { + if row < 0 || row >= len(m.view) { return nil } - a := m.agents[row] + a := m.view[row] if a.Cwd == "" { m.err = fmt.Errorf("no folder recorded for %s", a.Session) @@ -628,10 +638,10 @@ func openTuxedo(m model) tea.Cmd { var todoPath string var agent AgentState haveAgent := false - if len(m.agents) > 0 { + if len(m.view) > 0 { row := m.table.Cursor() - if row >= 0 && row < len(m.agents) { - agent = m.agents[row] + if row >= 0 && row < len(m.view) { + agent = m.view[row] haveAgent = true if agent.Cwd != "" { todoPath = agent.Cwd + "/.pi/dashboard/tasks/todo.txt" @@ -732,22 +742,32 @@ func printAgentList(agents []AgentState) { fmt.Println("No agents found in ~/.pi/agent/dashboard/") return } - fmt.Printf("%-6s %-18s %-16s %-10s %-10s %s\n", "Type", "Session", "Folder", "Status", "Started", "Task") - fmt.Println(strings.Repeat("-", 100)) + stale := 45 * time.Second + fmt.Printf("%-6s %-18s %-16s %-10s %-10s %-10s %s\n", "Type", "Session", "Folder", "Status", "Started", "Closed", "Task") + fmt.Println(strings.Repeat("-", 110)) for _, a := range agents { task := strings.ReplaceAll(a.CurrentTask, "\n", " ") if len(task) > 35 { task = task[:33] + ".." } - fmt.Printf("%-6s %-18s %-16s %-10s %-10s %s\n", + var closedTime string + if a.IsClosed(stale) { + if a.ClosedAt != nil && *a.ClosedAt != "" { + closedTime = fmtTime(a.ClosedAt) + } else { + closedTime = fmtTime(a.LastSeenAt) + } + } + fmt.Printf("%-6s %-18s %-16s %-10s %-10s %-10s %s\n", termSymbol(a.TerminalType, a.ConnectionType), trunc(a.Session, 18), trunc(a.Folder(), 16), statusStr(a), fmtTime(a.SessionStartedAt), + closedTime, task) } - fmt.Println(strings.Repeat("-", 100)) + fmt.Println(strings.Repeat("-", 110)) fmt.Println("Type: Ⓣ tmux · Ⓩ zellij · ⓉⓏ both · · direct") } diff --git a/main_test.go b/main_test.go index 136f766..e80959b 100644 --- a/main_test.go +++ b/main_test.go @@ -7,6 +7,8 @@ import ( "strings" "testing" "time" + + tea "github.com/charmbracelet/bubbletea" ) // Simulates the remote `cat *.json` output: several pretty-printed, @@ -117,4 +119,56 @@ func TestSortSmart(t *testing.T) { } } +// x toggles hiding of closed sessions both ways. +func TestHideClosedToggle(t *testing.T) { + now := time.Now().UTC().Format(time.RFC3339) + live := AgentState{Session: "live", AgentStatus: "idle", SessionState: "open", LastSeenAt: &now, PID: 1} + dead := AgentState{Session: "dead", AgentStatus: "idle", SessionState: "closed", LastSeenAt: &now, PID: 2} + + m := initialModel(defaultConfig()) + m.agents = []AgentState{live, dead} + m.updateTable() + if len(m.view) != 2 { + t.Fatalf("expected 2 shown initially, got %d", len(m.view)) + } + + // press x → hide closed + m2, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + mm := m2.(model) + if !mm.hideClosed { + t.Fatal("x should enable hideClosed") + } + if len(mm.view) != 1 || mm.view[0].Session != "live" { + t.Fatalf("expected only live session after hide, got %v", mm.view) + } + + // press x again → unhide + m3, _ := mm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + mmm := m3.(model) + if mmm.hideClosed { + t.Fatal("x should toggle hideClosed back off") + } + if len(mmm.view) != 2 { + t.Fatalf("expected 2 shown after unhide, got %d", len(mmm.view)) + } +} + +// A legacy (pre-heartbeat) live session must not be marked lost within the +// legacy window, even though it has no real heartbeat. +func TestLegacyWindow(t *testing.T) { + // mtime-based heartbeat, 5 minutes old: legacy window is 30m → live + fiveMin := time.Now().UTC().Add(-5 * time.Minute).Format(time.RFC3339) + legacyLive := AgentState{Session: "old-ext-session", Status: "running", LastSeenAt: &fiveMin} + if legacyLive.IsClosed(45 * time.Second) { + t.Fatal("legacy session with 5m-old mtime should be live (30m window)") + } + + // 2 hours old → closed + twoHours := time.Now().UTC().Add(-2 * time.Hour).Format(time.RFC3339) + legacyDead := AgentState{Session: "old-dead", Status: "running", LastSeenAt: &twoHours} + if !legacyDead.IsClosed(45 * time.Second) { + t.Fatal("legacy session with 2h-old mtime should be closed") + } +} + func strptr(s string) *string { return &s } diff --git a/state.go b/state.go index 8225c16..7095d63 100644 --- a/state.go +++ b/state.go @@ -12,34 +12,34 @@ import ( // AgentState matches the TypeScript interface from dashboard.ts type AgentState struct { - Session string `json:"session"` - Machine string `json:"machine"` - Cwd string `json:"cwd"` - AgentType string `json:"agent_type"` - Model string `json:"model"` - AgentStatus string `json:"agent_status"` - Status string `json:"status"` // legacy alias of agent_status - SessionState string `json:"session_state"` - SessionStartedAt *string `json:"session_started_at"` - ClosedAt *string `json:"closed_at"` - LastSeenAt *string `json:"last_seen_at"` - StartedAt *string `json:"started_at"` - CompletedAt *string `json:"completed_at"` - DurationSeconds int `json:"duration_seconds"` - ToolCalls int `json:"tool_calls"` - CurrentTask string `json:"current_task"` - LastTool *string `json:"last_tool"` - LastToolAt *string `json:"last_tool_at"` - TerminalType string `json:"terminal_type"` - ZellijSession string `json:"zellij_session"` - TmuxSession string `json:"tmux_session"` - PID int `json:"pid"` - IsSubagent bool `json:"is_subagent"` - ConnectionType string `json:"connection_type"` - ConnectionName string `json:"connection_name"` - Blocked bool `json:"blocked"` - BlockedPrompt *string `json:"blocked_prompt"` - CostEstimate float64 `json:"cost_estimate"` + Session string `json:"session"` + Machine string `json:"machine"` + Cwd string `json:"cwd"` + AgentType string `json:"agent_type"` + Model string `json:"model"` + AgentStatus string `json:"agent_status"` + Status string `json:"status"` // legacy alias of agent_status + SessionState string `json:"session_state"` + SessionStartedAt *string `json:"session_started_at"` + ClosedAt *string `json:"closed_at"` + LastSeenAt *string `json:"last_seen_at"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` + DurationSeconds int `json:"duration_seconds"` + ToolCalls int `json:"tool_calls"` + CurrentTask string `json:"current_task"` + LastTool *string `json:"last_tool"` + LastToolAt *string `json:"last_tool_at"` + TerminalType string `json:"terminal_type"` + ZellijSession string `json:"zellij_session"` + TmuxSession string `json:"tmux_session"` + PID int `json:"pid"` + IsSubagent bool `json:"is_subagent"` + ConnectionType string `json:"connection_type"` + ConnectionName string `json:"connection_name"` + Blocked bool `json:"blocked"` + BlockedPrompt *string `json:"blocked_prompt"` + CostEstimate float64 `json:"cost_estimate"` } // agentStatus returns the agent-turn status, falling back to the legacy @@ -51,6 +51,17 @@ func (a AgentState) agentStatus() string { return a.Status } +// legacyStale is the staleness window applied to legacy state files (written +// by the pre-heartbeat extension). Those files are only rewritten on events, +// so an idle-but-alive session can go minutes without a write — be generous. +const legacyStale = 30 * time.Minute + +// isLegacy reports whether the state file predates the heartbeat extension +// (new files always carry pid + session_state; legacy ones have neither). +func (a AgentState) isLegacy() bool { + return a.PID == 0 && a.SessionState == "" +} + // IsClosed reports whether the session is no longer alive: // - the extension marked it closed (session_state == "closed"), or // - the heartbeat (last_seen_at) is older than staleAfter (killed @@ -68,7 +79,11 @@ func (a AgentState) IsClosed(staleAfter time.Duration) bool { if err != nil { return true } - return time.Since(t) > staleAfter + window := staleAfter + if a.isLegacy() { + window = legacyStale + } + return time.Since(t) > window } // WasKilled is true when the session died without a graceful shutdown