TUI: Closed column, legacy 30m stale window, filtered-view fix, header shown-count, toggle tests

This commit is contained in:
2026-08-04 11:13:36 +10:00
parent 19b39de473
commit a0d25aa108
3 changed files with 141 additions and 52 deletions

66
main.go
View File

@@ -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")
}

View File

@@ -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 }

View File

@@ -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