okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
7f83187
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"albedo/cli/internal/daemon/protocol" 6"fmt" 7"math" 8"strconv" 9"strings" 10"time" 11 12"charm.land/lipgloss/v2" 13"github.com/charmbracelet/x/ansi" 14) 15 16// jobGrace is how long a job runs before the interface calls it background 17// work. Most model jobs finish inside it, so a job a cell starts and awaits 18// never lights the background-job chrome at all; one that survives is real 19// work and shows up as such. 20const jobGrace = 3 * time .Second 21 22// agedJobs are the live jobs past their grace: the work the sidebar and the 23// idle status line call background. A job with no start time is settled. 24func (m ChatModel )agedJobs () []protocol.KernelJob { 25jobs := make ([]protocol.KernelJob ,0 ,len (m .Status .RunningJobs )) 26for _ ,job := range m .Status .RunningJobs { 27if job .StartedAt <= 0 || time .Since (time .UnixMilli (job .StartedAt ))>= jobGrace { 28jobs = append (jobs ,job ) 29 } 30 } 31return jobs 32} 33 34// backgroundJobCount is the background work the interface names: aged jobs, 35// plus live remote jobs the observation could only count. 36func (m ChatModel )backgroundJobCount ()int64 { 37count := int64 (len (m .agedJobs ())) 38if m .Status .KernelJobs == nil { 39return count 40 } 41if extra := * m .Status .KernelJobs - int64 (len (m .Status .RunningJobs ));extra > 0 { 42count += extra 43 } 44return count 45} 46 47// activeGlances lists the sidebar's sections in order, leaving out empty ones: 48// the extensions' glances (active work), then background jobs past their grace. 49func (m ChatModel )activeGlances () []PageGlance { 50var glances []PageGlance 51for _ ,g := range m .Glances { 52if len (g .Rows )> 0 { 53glances = append (glances ,g ) 54 } 55 } 56jobs := m .agedJobs () 57if len (jobs )== 0 { 58return glances 59 } 60rows := make ([]PageRow ,0 ,len (jobs )) 61for _ ,job := range jobs { 62command := oneLine (job .Command ) 63if command == "" { 64command = "job " + job .ID 65 } 66rows = append (rows ,PageRow {Text :command ,Tone :ToneActive }) 67 } 68return append (glances ,PageGlance {Title :"background jobs" ,Rows :rows }) 69} 70 71// glanceCounts counts each glance the sidebar leaves out: all of them without 72// a sidebar, the ones that did not fit its height with one. 73func (m ChatModel )glanceCounts () []string { 74omitted := m .activeGlances () 75if m .sidebarWidth ()> 0 { 76_ ,omitted = m .sidebarRows () 77 } 78var counts []string 79for _ ,g := range omitted { 80counts = append (counts ,fmt .Sprintf ("%s %d" ,g .Title ,len (g .Rows ))) 81 } 82// a stale kernel is swapped before the next turn unless a job keeps it 83if m .Status .KernelStale && m .backgroundJobCount ()> 0 { 84counts = append ([]string {"kernel older until jobs end" },counts ... ) 85 } 86return counts 87} 88 89func (m ChatModel )renderGlances ()string { 90rows ,_ := m .sidebarRows () 91return lipgloss .NewStyle ().Width (m .sidebarWidth ()).Render (strings .Join (rows ,"\n" )) 92} 93 94// sidebarRows stacks the glances, a blank row apart and at most 12 rows each, 95// within the viewport's height. A glance with no room for its title and one 96// row is returned as omitted. 97func (m ChatModel )sidebarRows () ([]string , []PageGlance ) { 98var rows []string 99var omitted []PageGlance 100room := m .Viewport .Height () 101for _ ,g := range m .activeGlances () { 102gap := min (1 ,len (rows )) 103limit := min (room - gap ,12 ) 104if limit < 2 { 105omitted = append (omitted ,g ) 106continue 107 } 108section := m .glanceRows (g ,limit ) 109rows = append (rows ,make ([]string ,gap )... ) 110rows = append (rows ,section ... ) 111room -= gap + len (section ) 112 } 113return rows ,omitted 114} 115 116// glanceRows renders a glance's title and rows in at most limit rows, one 117// line per row. 118func (m ChatModel )glanceRows (g PageGlance ,limit int ) []string { 119shown := min (limit - 1 ,len (g .Rows )) 120if len (g .Rows )> shown { 121shown = limit - 2 122 } 123rows := []string {DefaultStyles .Faint .Render (fmt .Sprintf ("%s · %d" ,g .Title ,len (g .Rows )))} 124for _ ,item := range g .Rows [:shown ] { 125mark ,style := "○" ,DefaultStyles .Faint 126switch item .Tone { 127case ToneActive : 128mark ,style = "●" ,DefaultStyles .Success 129case ToneWarning : 130mark ,style = "!" ,DefaultStyles .Warning 131case ToneMuted : 132mark = "✓" 133 } 134label := oneLine (item .Text ) 135if item .ID != "" { 136label = "#" + item .ID + " " + label 137 } 138rows = append (rows ,style .Render (mark )+ " " + ansi .Truncate (label ,max (1 ,m .sidebarWidth ()- 2 ),"…" )) 139 } 140if shown < len (g .Rows ) { 141rows = append (rows ,DefaultStyles .Faint .Render (fmt .Sprintf ("+%d more" ,len (g .Rows )- shown ))) 142 } 143return rows 144} 145 146func (m ChatModel )renderFooter ()string { 147width := m .chatWidth () 148right ,compact := m .contextStat () 149commands := hint {"/" ,"commands" } 150sessions := hint {"←" ,"sessions" } 151candidates := []struct { 152right string 153left []hint 154 }{ 155 {left : []hint {sessions ,commands , {"shift+↑↓" ,"your messages" }, {"ctrl+j" ,"diffs" }, {"ctrl+o" ,"agents" }},right :right }, 156 {left : []hint {sessions ,commands , {"ctrl+j" ,"diffs" }, {"ctrl+o" ,"agents" }},right :right }, 157 {left : []hint {sessions ,commands , {"ctrl+o" ,"agents" }},right :compact }, 158 {left : []hint {sessions ,commands },right :compact }, 159 {left : []hint {commands , {"ctrl+j" ,"diffs" }},right :compact }, 160 {left : []hint {commands },right :compact }, 161 {left : []hint {{"/" ,"" }},right :compact }, 162 } 163for _ ,c := range candidates { 164left := keyHints (c .left ... ) 165if gap := width - lipgloss .Width (left )- lipgloss .Width (c .right );gap > 0 { 166return left + strings .Repeat (" " ,gap )+ c .right 167 } 168 } 169return ansi .Truncate (keyHints (commands ),width ,"…" ) 170} 171 172// contextStat is how much of the prompt was read from cache, fading as the 173// provider lets its cache go, and how full the context is: "392k/396k cached 174// (38%)", with a shorter form for narrow footers. The share turns yellow near 175// the window, where compaction starts. 176func (m ChatModel )contextStat () (full ,short string ) { 177usage := m .Usage 178if usage == nil || usage .Model != "" && m .Model != "" && usage .Model != m .Model { 179return DefaultStyles .Faint .Render ("—/— cached" ),DefaultStyles .Faint .Render ("—/—" ) 180 } 181count := func (n * int )string { 182if n == nil || * n < 0 { 183return "—" 184 } 185return shortCount (* n ) 186 } 187ratio := DefaultStyles .Muted .Render (count (cachedNow (usage ,time .Now ()))+ "/" + count (usage .PromptTokens )) 188share := "" 189if total := contextTokens (usage );total != nil && m .window != nil && m .windowModel != nil && * m .windowModel == usage .Model { 190pct := int (math .Round (100 * float64 (* total )/ float64 (* m .window ))) 191style := DefaultStyles .Faint 192if pct >= 90 { 193style = DefaultStyles .Warning 194 } 195share = " " + style .Render (fmt .Sprintf ("(%d%%)" ,pct )) 196 } 197return ratio + DefaultStyles .Faint .Render (" cached" )+ share ,ratio + share 198} 199 200// cachedNow is what a request would read from cache at now: the measured 201// count until the cache starts to fade, then each step it has reached. 202func cachedNow (u * daemon.Usage ,now time.Time )* int { 203cached := u .CachedPromptTokens 204for _ ,step := range u .CacheFade { 205if step .At > now .UnixMilli () { 206break 207 } 208cached = step .Cached 209 } 210return cached 211} 212 213// contextTokens is what the context holds after a reply: the prompt and the 214// completion, when the provider did not report a total. 215func contextTokens (u * daemon.Usage )* int { 216if u .TotalTokens != nil && * u .TotalTokens >= 0 { 217return u .TotalTokens 218 } 219if u .PromptTokens == nil || * u .PromptTokens < 0 { 220return nil 221 } 222total := * u .PromptTokens 223if u .CompletionTokens != nil && * u .CompletionTokens > 0 { 224total += * u .CompletionTokens 225 } 226return & total 227} 228 229// shortCount keeps token counts to about three digits: 812, 4.2k, 392k, 1.2m. 230func shortCount (n int )string { 231switch { 232case n < 1_000 : 233return strconv .Itoa (n ) 234case n < 10_000 : 235return strings .TrimSuffix (fmt .Sprintf ("%.1f" ,float64 (n )/ 1_000 ),".0" )+ "k" 236case n < 999_500 : 237return fmt .Sprintf ("%dk" ,int (math .Round (float64 (n )/ 1_000 ))) 238default : 239return strings .TrimSuffix (fmt .Sprintf ("%.1f" ,float64 (n )/ 1_000_000 ),".0" )+ "m" 240 } 241}