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 {
|
type model struct {
|
||||||
table table.Model
|
table table.Model
|
||||||
help help.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
|
config Config
|
||||||
hideClosed bool
|
hideClosed bool
|
||||||
hideSubagents bool
|
hideSubagents bool
|
||||||
@@ -86,10 +87,10 @@ func initialModel(cfg Config) model {
|
|||||||
{Title: "Session", Width: 16},
|
{Title: "Session", Width: 16},
|
||||||
{Title: "Folder", Width: 16},
|
{Title: "Folder", Width: 16},
|
||||||
{Title: "Status", Width: 10},
|
{Title: "Status", Width: 10},
|
||||||
{Title: "Model", Width: 14},
|
|
||||||
{Title: "Started", Width: 10},
|
{Title: "Started", Width: 10},
|
||||||
|
{Title: "Closed", Width: 10},
|
||||||
{Title: "Tools", Width: 5},
|
{Title: "Tools", Width: 5},
|
||||||
{Title: "Task", Width: 24},
|
{Title: "Task", Width: 20},
|
||||||
}
|
}
|
||||||
|
|
||||||
t := table.New(
|
t := table.New(
|
||||||
@@ -229,7 +230,7 @@ func (m model) View() string {
|
|||||||
live++
|
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 {
|
if m.hideClosed {
|
||||||
summary += " │ closed hidden"
|
summary += " │ closed hidden"
|
||||||
}
|
}
|
||||||
@@ -273,14 +274,14 @@ func (m model) View() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m model) detailView() string {
|
func (m model) detailView() string {
|
||||||
if len(m.agents) == 0 {
|
if len(m.view) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
row := m.table.Cursor()
|
row := m.table.Cursor()
|
||||||
if row < 0 || row >= len(m.agents) {
|
if row < 0 || row >= len(m.view) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
a := m.agents[row]
|
a := m.view[row]
|
||||||
|
|
||||||
stale := m.staleAfter()
|
stale := m.staleAfter()
|
||||||
term := a.TerminalType
|
term := a.TerminalType
|
||||||
@@ -373,6 +374,7 @@ func (m *model) updateTable() {
|
|||||||
}
|
}
|
||||||
view = append(view, a)
|
view = append(view, a)
|
||||||
}
|
}
|
||||||
|
m.view = view
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
sortAgents(view, m.sortMode, m.staleAfter())
|
sortAgents(view, m.sortMode, m.staleAfter())
|
||||||
@@ -385,16 +387,24 @@ func (m *model) updateTable() {
|
|||||||
session = "◇ " + session
|
session = "◇ " + session
|
||||||
}
|
}
|
||||||
task := strings.ReplaceAll(a.CurrentTask, "\n", " ")
|
task := strings.ReplaceAll(a.CurrentTask, "\n", " ")
|
||||||
if len(task) > 24 {
|
if len(task) > 20 {
|
||||||
task = task[:22] + ".."
|
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{
|
rows = append(rows, table.Row{
|
||||||
termSymbol(a.TerminalType, a.ConnectionType),
|
termSymbol(a.TerminalType, a.ConnectionType),
|
||||||
trunc(session, 16),
|
trunc(session, 16),
|
||||||
trunc(a.Folder(), 16),
|
trunc(a.Folder(), 16),
|
||||||
a.StatusStr(m.staleAfter()),
|
a.StatusStr(m.staleAfter()),
|
||||||
a.ShortModel(),
|
|
||||||
fmtTime(a.SessionStartedAt),
|
fmtTime(a.SessionStartedAt),
|
||||||
|
closedTime,
|
||||||
fmt.Sprintf("%d", a.ToolCalls),
|
fmt.Sprintf("%d", a.ToolCalls),
|
||||||
task,
|
task,
|
||||||
})
|
})
|
||||||
@@ -507,14 +517,14 @@ type errMsg struct{ err error }
|
|||||||
// `zellij action switch-session` when the dashboard itself runs inside zellij
|
// `zellij action switch-session` when the dashboard itself runs inside zellij
|
||||||
// - direct → no attach target; show an error
|
// - direct → no attach target; show an error
|
||||||
func attachToAgent(m model) tea.Cmd {
|
func attachToAgent(m model) tea.Cmd {
|
||||||
if len(m.agents) == 0 {
|
if len(m.view) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
row := m.table.Cursor()
|
row := m.table.Cursor()
|
||||||
if row < 0 || row >= len(m.agents) {
|
if row < 0 || row >= len(m.view) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
a := m.agents[row]
|
a := m.view[row]
|
||||||
|
|
||||||
term := a.TerminalType
|
term := a.TerminalType
|
||||||
if term == "" {
|
if term == "" {
|
||||||
@@ -573,14 +583,14 @@ func attachToAgent(m model) tea.Cmd {
|
|||||||
// openHunk reviews the selected agent's changes with Hunk, in the folder
|
// openHunk reviews the selected agent's changes with Hunk, in the folder
|
||||||
// the agent was actually running in.
|
// the agent was actually running in.
|
||||||
func openHunk(m model) tea.Cmd {
|
func openHunk(m model) tea.Cmd {
|
||||||
if len(m.agents) == 0 {
|
if len(m.view) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
row := m.table.Cursor()
|
row := m.table.Cursor()
|
||||||
if row < 0 || row >= len(m.agents) {
|
if row < 0 || row >= len(m.view) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
a := m.agents[row]
|
a := m.view[row]
|
||||||
|
|
||||||
if a.Cwd == "" {
|
if a.Cwd == "" {
|
||||||
m.err = fmt.Errorf("no folder recorded for %s", a.Session)
|
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 todoPath string
|
||||||
var agent AgentState
|
var agent AgentState
|
||||||
haveAgent := false
|
haveAgent := false
|
||||||
if len(m.agents) > 0 {
|
if len(m.view) > 0 {
|
||||||
row := m.table.Cursor()
|
row := m.table.Cursor()
|
||||||
if row >= 0 && row < len(m.agents) {
|
if row >= 0 && row < len(m.view) {
|
||||||
agent = m.agents[row]
|
agent = m.view[row]
|
||||||
haveAgent = true
|
haveAgent = true
|
||||||
if agent.Cwd != "" {
|
if agent.Cwd != "" {
|
||||||
todoPath = agent.Cwd + "/.pi/dashboard/tasks/todo.txt"
|
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/")
|
fmt.Println("No agents found in ~/.pi/agent/dashboard/")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("%-6s %-18s %-16s %-10s %-10s %s\n", "Type", "Session", "Folder", "Status", "Started", "Task")
|
stale := 45 * time.Second
|
||||||
fmt.Println(strings.Repeat("-", 100))
|
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 {
|
for _, a := range agents {
|
||||||
task := strings.ReplaceAll(a.CurrentTask, "\n", " ")
|
task := strings.ReplaceAll(a.CurrentTask, "\n", " ")
|
||||||
if len(task) > 35 {
|
if len(task) > 35 {
|
||||||
task = task[:33] + ".."
|
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),
|
termSymbol(a.TerminalType, a.ConnectionType),
|
||||||
trunc(a.Session, 18),
|
trunc(a.Session, 18),
|
||||||
trunc(a.Folder(), 16),
|
trunc(a.Folder(), 16),
|
||||||
statusStr(a),
|
statusStr(a),
|
||||||
fmtTime(a.SessionStartedAt),
|
fmtTime(a.SessionStartedAt),
|
||||||
|
closedTime,
|
||||||
task)
|
task)
|
||||||
}
|
}
|
||||||
fmt.Println(strings.Repeat("-", 100))
|
fmt.Println(strings.Repeat("-", 110))
|
||||||
fmt.Println("Type: Ⓣ tmux · Ⓩ zellij · ⓉⓏ both · · direct")
|
fmt.Println("Type: Ⓣ tmux · Ⓩ zellij · ⓉⓏ both · · direct")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
54
main_test.go
54
main_test.go
@@ -7,6 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Simulates the remote `cat *.json` output: several pretty-printed,
|
// 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 }
|
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
|
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:
|
// IsClosed reports whether the session is no longer alive:
|
||||||
// - the extension marked it closed (session_state == "closed"), or
|
// - the extension marked it closed (session_state == "closed"), or
|
||||||
// - the heartbeat (last_seen_at) is older than staleAfter (killed
|
// - 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 {
|
if err != nil {
|
||||||
return true
|
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
|
// WasKilled is true when the session died without a graceful shutdown
|
||||||
|
|||||||
Reference in New Issue
Block a user