TUI: Closed column, legacy 30m stale window, filtered-view fix, header shown-count, toggle tests
This commit is contained in:
66
main.go
66
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")
|
||||
}
|
||||
|
||||
|
||||
54
main_test.go
54
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 }
|
||||
|
||||
17
state.go
17
state.go
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user