@@ -13,6 +13,7 @@ import (
1313 "os"
1414 "path/filepath"
1515 "sort"
16+ "strconv"
1617 "strings"
1718 "time"
1819
@@ -400,7 +401,7 @@ func (m model) viewDashboard() string {
400401 cols := lipgloss .JoinHorizontal (lipgloss .Top , leftCol , " " , rightCol )
401402
402403 hosts := dashboardHosts (merged , innerW )
403- sessions := dashboardSessions (merged , innerW )
404+ sessions := dashboardSessions (merged , innerW , m . pricing )
404405 trends := dashboardTrends (merged , innerW )
405406
406407 keys := dimStyle .Render ("t tabbed · f filter host · j/k scroll · g/G top/end · r reload · q quit" )
@@ -424,7 +425,7 @@ func (m model) viewDashboard() string {
424425
425426// dashboardSessions is a compact view of the top 3 most expensive sessions in
426427// the window: live dot + title, with tokens/actions/model/age as dim meta.
427- func dashboardSessions (m merger.Merged , w int ) string {
428+ func dashboardSessions (m merger.Merged , w int , pricing pricingTable ) string {
428429 header := labelStyle .Render ("TOP SESSIONS" )
429430 if a := actionsSummary (m ); a != "" {
430431 header += dimStyle .Render (" · " + a )
@@ -447,14 +448,22 @@ func dashboardSessions(m merger.Merged, w int) string {
447448 label = s .Project
448449 }
449450 meta := []string {fmtTokens (s .Total ())}
451+ if c , ok := pricing .sessionCost (s ); ok {
452+ meta = append (meta , fmtUSD (c ))
453+ }
450454 if act := sessionActions (s ); act != "" {
451455 meta = append (meta , act )
452456 }
453457 meta = append (meta , prettyModel (s .Model ), short (now .Sub (time .Unix (s .LastAt , 0 )))+ " ago" )
454- rows = append (rows , fmt .Sprintf (" %s %-*s %s" ,
458+ marker := " "
459+ if isSpinning (s ) {
460+ marker = warnStyle .Render ("⚠" )
461+ }
462+ rows = append (rows , fmt .Sprintf (" %s %-*s %s %s" ,
455463 liveDot (s .LastAt ),
456464 labelW , truncate (label , labelW ),
457- dimStyle .Render (truncate (strings .Join (meta , " · " ), w - labelW - 5 )),
465+ marker ,
466+ dimStyle .Render (truncate (strings .Join (meta , " · " ), w - labelW - 7 )),
458467 ))
459468 }
460469 return strings .Join (rows , "\n " )
@@ -468,11 +477,15 @@ func (m model) dashboardHeader(w int) string {
468477 if m .machineFilter != "" {
469478 filterPart = " · filter " + warnStyle .Render (m .machineFilter )
470479 }
480+ livePart := ""
481+ if n := liveCount (merged ); n > 0 {
482+ livePart = dimStyle .Render (" · " ) + okStyle .Render (fmt .Sprintf ("%d live" , n ))
483+ }
471484 left := titleStyle .Render ("clawtop" ) +
472485 dimStyle .Render (" · " + time .Now ().Format ("15:04:05" )+
473486 " · hosts " + fmt .Sprintf ("%d (%s)" , len (merged .Machines ), joinHosts (merged .Machines ))+
474487 " · plan " + fallback (merged .Subscription , "?" )+
475- " · window " + fallback (merged .Window , "?" )) + filterPart + dimStyle .Render (" " )
488+ " · window " + fallback (merged .Window , "?" )) + livePart + filterPart + dimStyle .Render (" " )
476489 right := freshnessLabel (merged .Machines )
477490 gap := w - lipgloss .Width (left ) - lipgloss .Width (right )
478491 if gap < 1 {
@@ -569,19 +582,26 @@ func (m model) dashboardLimits(w int) string {
569582 if barW < 10 {
570583 barW = 10
571584 }
585+ sessDelta , weekDelta := "" , ""
586+ if merged .HasHistory {
587+ sessDelta = " " + dayDeltaLabel (merged .Session )
588+ weekDelta = " " + dayDeltaLabel (merged .Week )
589+ }
572590 return strings .Join ([]string {
573- fmt .Sprintf ("%-8s %s %s %s" ,
591+ fmt .Sprintf ("%-8s %s %s %s%s " ,
574592 labelStyle .Render ("SESSION" ),
575593 barRanked (merged .Session .Pct , barW ),
576594 pct (merged .Session .Pct ),
577595 dimStyle .Render ("· resets " + short (merged .Session .ResetIn ())+
578- projectionLine (merged .Session .Pct , sessR , merged .Session .ResetIn ()))),
579- fmt .Sprintf ("%-8s %s %s %s" ,
596+ projectionLine (merged .Session .Pct , sessR , merged .Session .ResetIn ())),
597+ sessDelta ),
598+ fmt .Sprintf ("%-8s %s %s %s%s" ,
580599 labelStyle .Render ("WEEK" ),
581600 barRanked (merged .Week .Pct , barW ),
582601 pct (merged .Week .Pct ),
583602 dimStyle .Render ("· resets " + short (merged .Week .ResetIn ())+
584- projectionLine (merged .Week .Pct , weekR , merged .Week .ResetIn ()))),
603+ projectionLine (merged .Week .Pct , weekR , merged .Week .ResetIn ())),
604+ weekDelta ),
585605 }, "\n " )
586606}
587607
@@ -648,7 +668,11 @@ func dashboardProjects(m merger.Merged, w, visible, scroll int) string {
648668func dashboardModels (m merger.Merged , w int , pricing pricingTable ) string {
649669 header := labelStyle .Render ("MODELS" )
650670 if total := pricing .totalCost (m .ByModel ); total > 0 {
651- header += dimStyle .Render (" · est. " + fmtUSD (total ))
671+ suffix := " · est. " + fmtUSD (total )
672+ if mo , ok := monthlyCost (total , m .Window ); ok {
673+ suffix += " · ~" + fmtUSD (mo ) + "/mo"
674+ }
675+ header += dimStyle .Render (suffix )
652676 }
653677 if len (m .ByModel ) == 0 {
654678 return strings .Join ([]string {header , dimStyle .Render ("no data" )}, "\n " )
@@ -695,7 +719,7 @@ func (m model) viewTabbed() string {
695719 case tabHosts :
696720 body = viewHosts (m .merged , innerW )
697721 case tabSessions :
698- body = viewSessions (m .merged , innerW )
722+ body = viewSessions (m .merged , innerW , m . pricing )
699723 case tabHeatmap :
700724 body = viewHeatmap (m .merged , innerW )
701725 case tabHourly :
@@ -745,14 +769,19 @@ func (m model) viewLimits(w int) string {
745769 merged := m .merged
746770 sessR , weekR := m .burnRate ()
747771 barW := w - 12
772+ sessDelta , weekDelta := "" , ""
773+ if merged .HasHistory {
774+ sessDelta = " " + dayDeltaLabel (merged .Session )
775+ weekDelta = " " + dayDeltaLabel (merged .Week )
776+ }
748777 return strings .Join ([]string {
749778 labelStyle .Render ("SESSION (5h)" ),
750- barRanked (merged .Session .Pct , barW ) + " " + pct (merged .Session .Pct ),
779+ barRanked (merged .Session .Pct , barW ) + " " + pct (merged .Session .Pct ) + sessDelta ,
751780 dimStyle .Render ("resets in " + short (merged .Session .ResetIn ()) +
752781 projectionLine (merged .Session .Pct , sessR , merged .Session .ResetIn ())),
753782 "" ,
754783 labelStyle .Render ("WEEK (7d)" ),
755- barRanked (merged .Week .Pct , barW ) + " " + pct (merged .Week .Pct ),
784+ barRanked (merged .Week .Pct , barW ) + " " + pct (merged .Week .Pct ) + weekDelta ,
756785 dimStyle .Render ("resets in " + short (merged .Week .ResetIn ()) +
757786 projectionLine (merged .Week .Pct , weekR , merged .Week .ResetIn ())),
758787 "" ,
@@ -839,7 +868,11 @@ func viewModels(m merger.Merged, w int, pricing pricingTable) string {
839868 }
840869 header := labelStyle .Render (fmt .Sprintf ("MODELS (last %s)" , fallback (m .Window , "?" )))
841870 if total := pricing .totalCost (m .ByModel ); total > 0 {
842- header += dimStyle .Render (" est. " + fmtUSD (total ) + " (list price, validate against billing)" )
871+ suffix := " est. " + fmtUSD (total )
872+ if mo , ok := monthlyCost (total , m .Window ); ok {
873+ suffix += " · ~" + fmtUSD (mo ) + "/mo"
874+ }
875+ header += dimStyle .Render (suffix + " (list price, validate against billing)" )
843876 }
844877 var maxT int64
845878 for _ , mm := range m .ByModel {
@@ -1005,15 +1038,15 @@ func trendLabel(curr, prev int64) string {
10051038// session takes two lines: a live-dot + title headline, then a dim meta line
10061039// with project, model, tokens, cache hit rate, action breakdown, duration and
10071040// last-seen. Useful to spot what each runaway conversation was actually doing.
1008- func viewSessions (m merger.Merged , w int ) string {
1041+ func viewSessions (m merger.Merged , w int , pricing pricingTable ) string {
10091042 header := labelStyle .Render (fmt .Sprintf ("TOP SESSIONS (last %s)" , fallback (m .Window , "?" )))
10101043 if a := actionsSummary (m ); a != "" {
10111044 header += dimStyle .Render (" · " + a )
10121045 }
10131046 if len (m .TopSessions ) == 0 {
10141047 return strings .Join ([]string {header , "" , dimStyle .Render ("no transcript data yet" )}, "\n " )
10151048 }
1016- rows := []string {header , "" , dimStyle .Render ("● live (touched <5m) actions: e=edits f=files r=reads b=bash" ), "" }
1049+ rows := []string {header , "" , dimStyle .Render ("● live (touched <5m) actions: e=edits f=files r=reads b=bash ⚠ tokens but no output " ), "" }
10171050 now := time .Now ()
10181051 for _ , s := range m .TopSessions {
10191052 dur := time .Duration (s .LastAt - s .StartedAt ) * time .Second
@@ -1022,11 +1055,18 @@ func viewSessions(m merger.Merged, w int) string {
10221055 if label == "" {
10231056 label = s .Project
10241057 }
1058+ title := truncate (label , w - 2 )
1059+ if isSpinning (s ) {
1060+ title += " " + warnStyle .Render ("⚠" )
1061+ }
10251062 meta := []string {
10261063 truncate (s .Project , 20 ),
10271064 truncate (prettyModel (s .Model ), 16 ),
10281065 fmtTokens (s .Total ()),
10291066 }
1067+ if c , ok := pricing .sessionCost (s ); ok {
1068+ meta = append (meta , fmtUSD (c ))
1069+ }
10301070 if s .In + s .CacheR > 0 {
10311071 meta = append (meta , fmt .Sprintf ("cache %.0f%%" , s .CacheHitRate ()))
10321072 }
@@ -1035,7 +1075,7 @@ func viewSessions(m merger.Merged, w int) string {
10351075 }
10361076 meta = append (meta , short (dur ), short (age )+ " ago" )
10371077 rows = append (rows ,
1038- fmt .Sprintf ("%s %s" , liveDot (s .LastAt ), truncate ( label , w - 2 ) ),
1078+ fmt .Sprintf ("%s %s" , liveDot (s .LastAt ), title ),
10391079 dimStyle .Render (" " + truncate (strings .Join (meta , " · " ), w - 2 )),
10401080 )
10411081 }
@@ -1163,6 +1203,58 @@ func pct(v float64) string {
11631203 return pctStyle .Render (fmt .Sprintf ("%5.1f%%" , v ))
11641204}
11651205
1206+ // dayDeltaLabel renders the percentage-point change of a rate-limit window vs
1207+ // ~24h ago. Rising utilization is bad (warn), falling is good (ok).
1208+ func dayDeltaLabel (w domain.Window ) string {
1209+ d := w .DayDelta ()
1210+ switch {
1211+ case d > 0.05 :
1212+ return warnStyle .Render (fmt .Sprintf ("↑%.1fpp/24h" , d ))
1213+ case d < - 0.05 :
1214+ return okStyle .Render (fmt .Sprintf ("↓%.1fpp/24h" , - d ))
1215+ default :
1216+ return dimStyle .Render ("≈flat/24h" )
1217+ }
1218+ }
1219+
1220+ // windowDuration parses the daemon's window label ("24h", "7d", "30d", or a
1221+ // Go duration string) into a duration. Zero when unparseable.
1222+ func windowDuration (s string ) time.Duration {
1223+ if s == "" {
1224+ return 0
1225+ }
1226+ if n , err := strconv .Atoi (strings .TrimSuffix (s , "d" )); err == nil && strings .HasSuffix (s , "d" ) {
1227+ return time .Duration (n ) * 24 * time .Hour
1228+ }
1229+ if d , err := time .ParseDuration (s ); err == nil {
1230+ return d
1231+ }
1232+ return 0
1233+ }
1234+
1235+ // monthlyCost extrapolates the window's total cost to a 30-day run rate. ok is
1236+ // false when the window is unknown or there's no cost yet.
1237+ func monthlyCost (total float64 , window string ) (float64 , bool ) {
1238+ d := windowDuration (window )
1239+ if d <= 0 || total <= 0 {
1240+ return 0 , false
1241+ }
1242+ month := 30 * 24 * time .Hour
1243+ return total * float64 (month ) / float64 (d ), true
1244+ }
1245+
1246+ // liveCount returns how many of the top sessions were touched in the last 5
1247+ // minutes. Note: scoped to the top sessions list, not every session.
1248+ func liveCount (m merger.Merged ) int {
1249+ n := 0
1250+ for _ , s := range m .TopSessions {
1251+ if s .LastAt > 0 && time .Since (time .Unix (s .LastAt , 0 )) < 5 * time .Minute {
1252+ n ++
1253+ }
1254+ }
1255+ return n
1256+ }
1257+
11661258// liveDot returns a green ● for sessions touched within the last 5 minutes
11671259// (still active), else a dim ○ (idle/done).
11681260func liveDot (lastAt int64 ) string {
@@ -1191,6 +1283,18 @@ func modelCostSuffix(pricing pricingTable, mm domain.Model) string {
11911283 return ""
11921284}
11931285
1286+ // spinThreshold is the token count above which a session that produced no
1287+ // concrete output is considered to be "spinning" (stuck exploring/discussing
1288+ // without acting). Picked to ignore small read-only Q&A sessions.
1289+ const spinThreshold = 200_000
1290+
1291+ // isSpinning flags a session that burned significant tokens but made no edits,
1292+ // touched no files, and ran no shell commands — usually the model stuck in a
1293+ // loop rather than doing work.
1294+ func isSpinning (s domain.SessionStat ) bool {
1295+ return s .Total () >= spinThreshold && s .Edits == 0 && s .FilesTouched == 0 && s .Bash == 0
1296+ }
1297+
11941298// sessionActions renders a per-session action breakdown like "12e 3f 40r 8b"
11951299// (edits, files touched, reads, bash). Empty when the session did nothing.
11961300func sessionActions (s domain.SessionStat ) string {
0 commit comments