diff --git a/internal/session/filter.go b/internal/session/filter.go index a927aef..69c3788 100644 --- a/internal/session/filter.go +++ b/internal/session/filter.go @@ -35,6 +35,16 @@ func FilterValueFor(s Session, cwdProjectPaths []string) string { if s.IsResponding { parts = append(parts, "is:busy") } + switch s.Lifecycle() { + case LifecycleBG: + parts = append(parts, "is:bg") + case LifecycleWait: + parts = append(parts, "is:wait") + case LifecycleDone: + parts = append(parts, "is:done") + case LifecycleStuck: + parts = append(parts, "is:stuck") + } if s.IsWorktree { parts = append(parts, "is:wt") } diff --git a/internal/session/filter_test.go b/internal/session/filter_test.go index 5e54e64..19ce76b 100644 --- a/internal/session/filter_test.go +++ b/internal/session/filter_test.go @@ -3,6 +3,7 @@ package session import ( "strings" "testing" + "time" ) func TestFilterValueFor_Basics(t *testing.T) { @@ -89,6 +90,53 @@ func TestMatches(t *testing.T) { } } +func TestFilterValueFor_LifecycleTokens(t *testing.T) { + now := time.Now() + cases := []struct { + name string + sess Session + want string + notWant []string + }{ + { + name: "bg", + sess: Session{IsLive: true, HasShellJobs: true, ModTime: now}, + want: "is:bg", + }, + { + name: "wait", + sess: Session{IsLive: true, ModTime: now, Todos: []TodoItem{{Status: "in_progress"}}}, + want: "is:wait", + }, + { + name: "done", + sess: Session{ModTime: now.Add(-time.Hour), Todos: []TodoItem{{Status: "completed"}}}, + want: "is:done", + }, + { + name: "stuck", + sess: Session{IsLive: true, ModTime: now.Add(-time.Hour), Tasks: []TaskItem{{Status: "pending"}}}, + want: "is:stuck", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fv := FilterValueFor(tc.sess, nil) + if !strings.Contains(fv, tc.want) { + t.Errorf("expected %q in %q", tc.want, fv) + } + for _, other := range []string{"is:bg", "is:wait", "is:done", "is:stuck"} { + if other == tc.want { + continue + } + if strings.Contains(fv, other) { + t.Errorf("did not expect %q in %q", other, fv) + } + } + }) + } +} + func mustContain(t *testing.T, haystack string, needles ...string) { t.Helper() for _, n := range needles { diff --git a/internal/session/lifecycle.go b/internal/session/lifecycle.go new file mode 100644 index 0000000..06affbe --- /dev/null +++ b/internal/session/lifecycle.go @@ -0,0 +1,117 @@ +package session + +import "time" + +// LifecycleState describes the current high-level state of a session. +// +// - LifecycleBusy: Claude is actively responding (file mtime within ~10s). +// - LifecycleBG: A background shell, Monitor, or active cron is in flight. +// - LifecycleStuck: Live process exists but file is stale beyond stuckThreshold +// while there is still unfinished work to do. +// - LifecycleWait: Live, idle, and has unfinished todos/tasks — waiting for +// the user to continue. +// - LifecycleDone: Session had todos/tasks and they are all completed. +// - LifecycleNone: No notable state to surface. +type LifecycleState int + +const ( + LifecycleNone LifecycleState = iota + LifecycleDone + LifecycleWait + LifecycleStuck + LifecycleBG + LifecycleBusy +) + +// stuckThreshold is how stale a live session's mtime must be before we call it +// stuck. Anything within this window is considered just "idle/waiting". +const stuckThreshold = 30 * time.Minute + +// Lifecycle returns the single highest-priority lifecycle state for the +// session. The mapping is intentionally mutually exclusive: at most one of +// [BG]/[WAIT]/[DONE]/[STUCK] is rendered in addition to [LIVE]/[BUSY]. +func (s Session) Lifecycle() LifecycleState { + if s.IsResponding { + return LifecycleBusy + } + if s.hasActiveBG() { + return LifecycleBG + } + if s.IsLive && s.isStaleStuck() { + return LifecycleStuck + } + if s.IsLive && s.hasUnfinishedWork() { + return LifecycleWait + } + if s.allWorkCompleted() { + return LifecycleDone + } + return LifecycleNone +} + +// hasActiveBG reports whether the session has background work that is still +// expected to be running. We treat any session that contains a shell or +// Monitor invocation as "BG-capable" while the Claude process is live, and we +// treat active (not-yet-deleted) crons as BG regardless of liveness because +// crons fire on their own schedule. +func (s Session) hasActiveBG() bool { + if s.IsLive && s.HasShellJobs { + return true + } + for _, c := range s.Crons { + if c.Status == "active" { + return true + } + } + return false +} + +// hasUnfinishedWork reports whether the session has any todo or task in a +// non-completed state. +func (s Session) hasUnfinishedWork() bool { + for _, t := range s.Todos { + if t.Status == "pending" || t.Status == "in_progress" { + return true + } + } + for _, t := range s.Tasks { + if t.Status == "pending" || t.Status == "in_progress" { + return true + } + } + return false +} + +// allWorkCompleted reports whether the session had todos or tasks AND all of +// them are completed. Empty sessions (no todos/tasks recorded) are not +// considered "done" — we don't have enough signal to say so. +func (s Session) allWorkCompleted() bool { + if len(s.Todos) == 0 && len(s.Tasks) == 0 { + return false + } + for _, t := range s.Todos { + if t.Status != "completed" { + return false + } + } + for _, t := range s.Tasks { + if t.Status != "completed" { + return false + } + } + return true +} + +// isStaleStuck reports whether a live session has not updated its JSONL for +// long enough that we suspect it is stuck — but only if there is still +// unfinished work to do. A live session sitting idle with nothing pending is +// not stuck; it is simply waiting for the next prompt. +func (s Session) isStaleStuck() bool { + if s.ModTime.IsZero() { + return false + } + if time.Since(s.ModTime) < stuckThreshold { + return false + } + return s.hasUnfinishedWork() +} diff --git a/internal/session/lifecycle_test.go b/internal/session/lifecycle_test.go new file mode 100644 index 0000000..123c620 --- /dev/null +++ b/internal/session/lifecycle_test.go @@ -0,0 +1,123 @@ +package session + +import ( + "testing" + "time" +) + +func TestLifecycle(t *testing.T) { + now := time.Now() + cases := []struct { + name string + sess Session + want LifecycleState + }{ + { + name: "responding wins over everything", + sess: Session{ + IsLive: true, + IsResponding: true, + HasShellJobs: true, + Todos: []TodoItem{{Status: "pending"}}, + }, + want: LifecycleBusy, + }, + { + name: "live shell job → BG", + sess: Session{ + IsLive: true, + HasShellJobs: true, + ModTime: now, + }, + want: LifecycleBG, + }, + { + name: "active cron → BG even when not live", + sess: Session{ + Crons: []CronItem{{Status: "active"}}, + ModTime: now, + }, + want: LifecycleBG, + }, + { + name: "deleted cron is not BG", + sess: Session{ + Crons: []CronItem{{Status: "deleted"}}, + ModTime: now, + }, + want: LifecycleNone, + }, + { + name: "shell jobs on a dead session are ignored", + sess: Session{ + HasShellJobs: true, + ModTime: now, + }, + want: LifecycleNone, + }, + { + name: "live + stale + unfinished → STUCK", + sess: Session{ + IsLive: true, + ModTime: now.Add(-45 * time.Minute), + Todos: []TodoItem{{Status: "in_progress"}}, + }, + want: LifecycleStuck, + }, + { + name: "live + stale but no unfinished work → not stuck", + sess: Session{ + IsLive: true, + ModTime: now.Add(-45 * time.Minute), + Todos: []TodoItem{{Status: "completed"}}, + }, + want: LifecycleDone, + }, + { + name: "live + fresh + unfinished → WAIT", + sess: Session{ + IsLive: true, + ModTime: now, + Tasks: []TaskItem{{Status: "pending"}}, + }, + want: LifecycleWait, + }, + { + name: "live + nothing pending → none", + sess: Session{ + IsLive: true, + ModTime: now, + }, + want: LifecycleNone, + }, + { + name: "non-live + all completed todos → DONE", + sess: Session{ + ModTime: now.Add(-2 * time.Hour), + Todos: []TodoItem{{Status: "completed"}, {Status: "completed"}}, + }, + want: LifecycleDone, + }, + { + name: "non-live + mixed todos → none (no signal we trust)", + sess: Session{ + ModTime: now.Add(-2 * time.Hour), + Todos: []TodoItem{{Status: "completed"}, {Status: "pending"}}, + }, + want: LifecycleNone, + }, + { + name: "empty session → none", + sess: Session{ModTime: now}, + want: LifecycleNone, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.sess.Lifecycle(); got != tc.want { + t.Errorf("Lifecycle() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/tui/cmdmode.go b/internal/tui/cmdmode.go index 7893b1c..447356e 100644 --- a/internal/tui/cmdmode.go +++ b/internal/tui/cmdmode.go @@ -286,9 +286,9 @@ func buildCmdRegistry() []cmdEntry { action: func(a *App) (tea.Model, tea.Cmd) { return a.bootstrapAndEditConfig() }}, // Badges - {name: "badge:toggle", aliases: []string{"bt"}, desc: "toggle badge (M,W,T,K,P,A,C,S,X,F,LIVE)", + {name: "badge:toggle", aliases: []string{"bt"}, desc: "toggle badge (HERE,LIVE,BUSY,BG,WAIT,DONE,STUCK)", action: func(a *App) (tea.Model, tea.Cmd) { - a.copiedMsg = "Usage: badge:toggle (M,W,T,K,P,A,C,S,X,F,LIVE)" + a.copiedMsg = "Usage: badge:toggle (HERE,LIVE,BUSY,BG,WAIT,DONE,STUCK)" return a, nil }}, @@ -700,11 +700,11 @@ func (a *App) executeCmdBadgeRm(input string) (tea.Model, tea.Cmd) { func (a *App) executeCmdBadgeToggle(input string) (tea.Model, tea.Cmd) { parts := strings.Fields(input) if len(parts) < 2 { - a.copiedMsg = "Usage: badge:toggle (M,W,T,K,P,A,C,S,X,F,LIVE)" + a.copiedMsg = "Usage: badge:toggle (HERE,LIVE,BUSY,BG,WAIT,DONE,STUCK)" return a, nil } key := strings.ToUpper(parts[len(parts)-1]) - valid := map[string]bool{"M": true, "W": true, "T": true, "K": true, "P": true, "A": true, "C": true, "S": true, "X": true, "F": true, "LIVE": true} + valid := map[string]bool{"HERE": true, "LIVE": true, "BUSY": true, "BG": true, "WAIT": true, "DONE": true, "STUCK": true} if !valid[key] { a.copiedMsg = "Unknown badge: " + key return a, nil diff --git a/internal/tui/sessions.go b/internal/tui/sessions.go index d245ce9..16be2fc 100644 --- a/internal/tui/sessions.go +++ b/internal/tui/sessions.go @@ -243,56 +243,35 @@ func (d sessionDelegate) Render(w io.Writer, m list.Model, index int, item list. badgesW += 7 } if s.IsLive && !hide["LIVE"] { - if s.IsResponding { - badges += " " + busyBadge.Render("[BUSY]") - } else { - badges += " " + liveBadge.Render("[LIVE]") - } + badges += " " + liveBadge.Render("[LIVE]") badgesW += 7 } - if s.HasMemory && !hide["M"] { - badges += " " + memoryBadge.Render("[M]") - badgesW += 4 - } - if s.IsWorktree && !hide["W"] { - badges += " " + worktreeBadge.Render("[W]") - badgesW += 4 - } - if s.HasTodos && !hide["T"] { - badges += " " + todoBadge.Render("[T]") - badgesW += 4 - } - if s.HasTasks && !hide["K"] { - badges += " " + taskBadge.Render("[K]") - badgesW += 4 - } - if s.HasPlan && !hide["P"] { - badges += " " + planBadge.Render("[P]") - badgesW += 4 - } - if s.HasAgents && !hide["A"] { - badges += " " + agentBadgeStyle.Render("[A]") - badgesW += 4 - } - if s.HasCompaction && !hide["C"] { - badges += " " + compactBadgeStyle.Render("[C]") - badgesW += 4 - } - if s.HasSkills && !hide["S"] { - badges += " " + todoBadge.Render("[S]") - badgesW += 4 - } - if s.HasMCP && !hide["X"] { - badges += " " + mcpBadgeStyle.Render("[X]") - badgesW += 4 - } - if s.HasShellJobs && !hide["B"] { - badges += " " + shellBadge.Render("[B]") - badgesW += 4 - } - if s.ParentSessionID != "" && !hide["F"] { - badges += " " + forkBadge.Render("[F]") - badgesW += 4 + switch s.Lifecycle() { + case session.LifecycleBusy: + if !hide["BUSY"] { + badges += " " + busyBadge.Render("[BUSY]") + badgesW += 7 + } + case session.LifecycleBG: + if !hide["BG"] { + badges += " " + bgBadgeStyle.Render("[BG]") + badgesW += 5 + } + case session.LifecycleStuck: + if !hide["STUCK"] { + badges += " " + stuckBadgeStyle.Render("[STUCK]") + badgesW += 8 + } + case session.LifecycleWait: + if !hide["WAIT"] { + badges += " " + waitBadgeStyle.Render("[WAIT]") + badgesW += 7 + } + case session.LifecycleDone: + if !hide["DONE"] { + badges += " " + doneBadgeStyle.Render("[DONE]") + badgesW += 7 + } } // Custom user badges for _, badge := range s.CustomBadges { @@ -1030,18 +1009,11 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st allBadges := []badge{ {hereBadge, "[HERE]", "In current tmux window"}, {liveBadge, "[LIVE]", "Running Claude"}, - {busyBadge, "[BUSY]", "Responding"}, - {memoryBadge, "[M]", "Has memory"}, - {worktreeBadge, "[W]", "Git worktree"}, - {todoBadge, "[T]", "Has todos"}, - {taskBadge, "[K]", "Has tasks"}, - {cronBadge, "[R]", "Has cron jobs"}, - {planBadge, "[P]", "Has plan"}, - {agentBadgeStyle, "[A]", "Has subagents"}, - {compactBadgeStyle, "[C]", "Compacted"}, - {todoBadge, "[S]", "Used skills"}, - {mcpBadgeStyle, "[X]", "Used MCP"}, - {forkBadge, "[F]", "Forked session"}, + {busyBadge, "[BUSY]", "Responding now"}, + {bgBadgeStyle, "[BG]", "Background shell/monitor/cron"}, + {waitBadgeStyle, "[WAIT]", "Idle, waiting for user"}, + {doneBadgeStyle, "[DONE]", "All work completed"}, + {stuckBadgeStyle, "[STUCK]", "Live but stale with unfinished work"}, {lipgloss.NewStyle().Foreground(lipgloss.Color("#7C3AED")).Bold(true), "[R·exp]", "Remote (experimental)"}, } // Render badges in pairs (two per line) @@ -1063,7 +1035,11 @@ func renderHelpModal(bg string, screenW, screenH int, km Keymap, shortcutHint st allFilters := []filter{ {"is:here", "In current window"}, {"is:live", "Live sessions"}, - {"is:busy", "Busy sessions"}, + {"is:busy", "Responding now"}, + {"is:bg", "Background work in flight"}, + {"is:wait", "Idle, waiting for user"}, + {"is:done", "All work completed"}, + {"is:stuck", "Stale, unfinished"}, {"is:wt", "Worktree sessions"}, {"is:team", "Team sessions"}, {"has:mem", "With memory"}, diff --git a/internal/tui/styles.go b/internal/tui/styles.go index 427186c..3240069 100644 --- a/internal/tui/styles.go +++ b/internal/tui/styles.go @@ -25,23 +25,21 @@ var ( dimStyle = lipgloss.NewStyle().Foreground(colorDim) selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#D1D5DB")) selectedRowStyle = lipgloss.NewStyle().Background(lipgloss.Color("#1E293B")) - worktreeBadge = lipgloss.NewStyle().Foreground(colorWorktree).Bold(true) filterBadge = lipgloss.NewStyle().Foreground(colorFilter).Bold(true) teamBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#06B6D4")).Bold(true) agentBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#06B6D4")).Bold(true) compactBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) - mcpBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F472B6")).Bold(true) taskBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FB923C")).Bold(true) memoryBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#FBBF24")).Bold(true) - todoBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) - taskBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#FB923C")).Bold(true) - cronBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")).Bold(true) planBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#A78BFA")).Bold(true) - shellBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) liveBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E")).Bold(true) busyBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Bold(true) forkBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B")).Bold(true) hereBadge = lipgloss.NewStyle().Foreground(lipgloss.Color("#F472B6")).Bold(true) + bgBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#22D3EE")).Bold(true) + waitBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FBBF24")).Bold(true) + doneBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#10B981")).Bold(true) + stuckBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444")).Bold(true) customBadgeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#84CC16")).Bold(true).Italic(true) blockCursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#38BDF8")).Bold(true) blockSelectedBg = lipgloss.NewStyle().Background(lipgloss.Color("#1E293B"))