Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/session/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
48 changes: 48 additions & 0 deletions internal/session/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package session
import (
"strings"
"testing"
"time"
)

func TestFilterValueFor_Basics(t *testing.T) {
Expand Down Expand Up @@ -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 {
Expand Down
117 changes: 117 additions & 0 deletions internal/session/lifecycle.go
Original file line number Diff line number Diff line change
@@ -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()
}
123 changes: 123 additions & 0 deletions internal/session/lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
8 changes: 4 additions & 4 deletions internal/tui/cmdmode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <KEY> (M,W,T,K,P,A,C,S,X,F,LIVE)"
a.copiedMsg = "Usage: badge:toggle <KEY> (HERE,LIVE,BUSY,BG,WAIT,DONE,STUCK)"
return a, nil
}},

Expand Down Expand Up @@ -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 <KEY> (M,W,T,K,P,A,C,S,X,F,LIVE)"
a.copiedMsg = "Usage: badge:toggle <KEY> (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
Expand Down
Loading
Loading