okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
2f6c51d
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"context" 7"errors" 8"fmt" 9"slices" 10"strings" 11 12 tea"charm.land/bubbletea/v2" 13"github.com/charmbracelet/x/ansi" 14"golang.org/x/text/language" 15"golang.org/x/text/message" 16) 17 18// ContextDetail is one section's content, read a page at a time and scrolled 19// line by line. 20type ContextDetail struct { 21Value * daemon.ContextPage 22Error string 23Section daemon.ContextSection 24Page int 25Scroll int 26// visible is the body rows the last frame drew; page jumps move by it. 27visible int 28// wrapped is wrappedFrom's content at wrappedWidth, kept because every 29// scroll step reads it and a page can be long. 30wrapped []string 31wrappedWidth int 32wrappedFrom * daemon.ContextPage 33} 34 35// rows is the page's content wrapped to width. 36func (d * ContextDetail )rows (width int ) []string { 37if d .Value == nil { 38return nil 39 } 40if d .wrappedFrom != d .Value || d .wrappedWidth != width { 41d .wrapped ,d .wrappedWidth ,d .wrappedFrom = wrapContextContent (d .Value .Content ,width ),width ,d .Value 42 } 43return d .wrapped 44} 45 46// scroll moves the content by lines, within the rows the last frame showed. 47func (d * ContextDetail )scroll (by int ) { 48d .Scroll = min (max (0 ,d .Scroll + by ),max (0 ,len (d .wrapped )- d .visible )) 49} 50 51// list is the reader's body at exactly width × height: where the page comes 52// from, any warning, then the scrolled rows. 53func (d * ContextDetail )list (width ,height int ) []string { 54sec := d .Section 55head := []string {DefaultStyles .Faint .Render (fmt .Sprintf ("%s · page %d/%d · %s" ,sec .Source ,d .Page + 1 ,sec .Pages ,contextCount (sec .ByteCount ,"byte" )))} 56var rows []string 57switch { 58case d .Error != "" : 59head = append (head ,DefaultStyles .Error .Render (d .Error )) 60case d .Value == nil : 61head = append (head ,DefaultStyles .Faint .Render ("loading inspectable prepared content…" )) 62default : 63if d .Value .Omitted != "" { 64head = append (head ,DefaultStyles .Warning .Render ("omitted: " + d .Value .Omitted )) 65 } 66head = append (head ,"" ) 67rows = d .rows (max (1 ,width - 2 )) 68 } 69room := max (0 ,height - len (head )) 70d .visible = room 71d .Scroll = min (d .Scroll ,max (0 ,len (rows )- room )) 72 73out := make ([]string ,0 ,height ) 74for _ ,line := range head { 75out = append (out ," " + line ) 76 } 77for _ ,row := range rows [d .Scroll :min (len (rows ),d .Scroll + room )] { 78out = append (out ," " + row ) 79 } 80for len (out )< height { 81out = append (out ,"" ) 82 } 83return out 84} 85 86type ContextDoneMsg struct {} 87 88type contextSnapshotLoadedMsg struct { 89Snapshot * daemon.ContextSnapshot 90Err error 91Gen int 92} 93 94type contextPageLoadedMsg struct { 95Err error 96Data * daemon.ContextPage 97SectionID string 98Page int 99Gen int 100} 101 102// ContextInspectorModel lists the sections of the prepared request beside 103// the highlighted section's facts. Enter reads a section's content. 104type ContextInspectorModel struct { 105Conn * daemon.Connection 106SessionID string 107Snapshot * daemon.ContextSnapshot 108Detail * ContextDetail 109pageStatus 110listView 111} 112 113func NewContextInspectorModel (conn * daemon.Connection ,sessionID string )ContextInspectorModel { 114return ContextInspectorModel { 115Conn :conn , 116SessionID :sessionID , 117pageStatus :newPageStatus (true ), 118listView :newListView ("Search sections" ), 119 } 120} 121 122func (m * ContextInspectorModel )SetSize (width ,height int ) { 123m .pageStatus .SetSize (width ,height ) 124m .listView .setSize (width ,height ) 125} 126 127func (m ContextInspectorModel )Init () tea.Cmd { 128return m .loadSnapshotCmd (m .Generation ) 129} 130 131var compactionStatuses = map [string ]string { 132"not_configured" :"not configured" ,"not_needed" :"not needed" ,"compacted" :"applied" ,"unknown" :"state unavailable" , 133} 134 135func (m ContextInspectorModel )loadSnapshotCmd (gen int ) tea.Cmd { 136return func () tea.Msg { 137if m .Conn == nil { 138return contextSnapshotLoadedMsg {Err :errors .New ("daemon connection unavailable" ),Gen :gen } 139 } 140 141snapshot ,err := daemon .GetContextSnapshot (context .Background (),m .Conn ,m .SessionID ) 142if err != nil { 143return contextSnapshotLoadedMsg {Err :err ,Gen :gen } 144 } 145return contextSnapshotLoadedMsg {Snapshot :& snapshot ,Gen :gen } 146 } 147} 148 149func (m ContextInspectorModel )loadPageCmd (sectionID string ,page int ,gen int ) tea.Cmd { 150return func () tea.Msg { 151if m .Conn == nil { 152return contextPageLoadedMsg {SectionID :sectionID ,Page :page ,Err :errors .New ("daemon connection unavailable" ),Gen :gen } 153 } 154data ,err := daemon .GetContextPage (context .Background (),m .Conn ,m .SessionID ,m .Snapshot .SnapshotID ,sectionID ,page ) 155if err != nil { 156return contextPageLoadedMsg {SectionID :sectionID ,Page :page ,Err :err ,Gen :gen } 157 } 158return contextPageLoadedMsg {SectionID :sectionID ,Page :page ,Data :& data ,Gen :gen } 159 } 160} 161 162// current is the section under the cursor. 163func (m ContextInspectorModel )current () (daemon.ContextSection ,bool ) { 164row ,ok := m .highlighted () 165if ! ok || m .Snapshot == nil { 166return daemon.ContextSection {},false 167 } 168i := slices .IndexFunc (m .Snapshot .Sections ,func (sec daemon.ContextSection )bool {return sec .ID == row .key }) 169if i < 0 { 170return daemon.ContextSection {},false 171 } 172return m .Snapshot .Sections [i ],true 173} 174 175func (m * ContextInspectorModel )setSections () { 176var rows []listEntry 177if m .Snapshot != nil { 178rows = make ([]listEntry ,len (m .Snapshot .Sections )) 179for i ,sec := range m .Snapshot .Sections { 180rows [i ]= sectionEntry (i ,sec ) 181 } 182 } 183m .setRows (rows ) 184} 185 186func sectionEntry (i int ,sec daemon.ContextSection )listEntry { 187tag := DefaultStyles .Faint .Render ("no content" ) 188if sec .Pages > 0 { 189tag = DefaultStyles .Muted .Render (contextCount (sec .Pages ,"page" )) 190 } 191return listEntry { 192key :sec .ID , 193lead :DefaultStyles .Faint .Render (fmt .Sprintf ("%d." ,i + 1 )), 194name :sec .Label , 195desc :sec .Source , 196tag :tag , 197search : []string {sec .Kind ,sec .Preview }, 198detail :func (width int ) []string {return sectionDetails (sec ,width ) }, 199 } 200} 201 202// sectionDetails is the pane: where a section comes from, how much of it 203// there is, and its preview. 204func sectionDetails (sec daemon.ContextSection ,width int ) []string { 205pages := "content unavailable" 206if sec .Pages > 0 { 207pages = contextCount (sec .Pages ,"page" ) 208 } 209lines := paneTitle (sec .Label ,"" ,width ) 210lines = append (lines ,factRows ("source" ,sec .Source ,width )... ) 211lines = append (lines ,factRows ("items" ,contextCount (sec .ItemCount ,"item" ),width )... ) 212lines = append (lines ,factRows ("bytes" ,contextCount (sec .ByteCount ,"measured byte" ),width )... ) 213lines = append (lines ,factRows ("pages" ,pages ,width )... ) 214if sec .Preview != "" { 215lines = append (lines ,"" ) 216lines = append (lines ,factRows ("preview" ,sec .Preview ,width )... ) 217 } 218lines = append (lines ,"" ) 219return append (lines ,paneNote ("enter reads this section a page at a time" ,width )... ) 220} 221 222// requestFacts is the prepared request's standing, drawn above the sections 223// so it reads without opening one. 224func (m ContextInspectorModel )requestFacts (width int ) []string { 225snap := m .Snapshot 226if snap == nil { 227return nil 228 } 229if snap .State == "pending" { 230value := "not prepared for this running session yet" 231if snap .Reason != "" { 232value += ". " + snap .Reason 233 } 234return factRows ("request" ,value ,width ) 235 } 236label := snap .Model 237if snap .Provider != "" { 238label = snap .Provider + " · " + snap .Model 239 } 240if snap .Protocol != "" { 241label += " · " + snap .Protocol 242 } 243window := "not reported" 244if snap .ContextWindowTokens != nil { 245window = contextCount (* snap .ContextWindowTokens ,"token" )+ " (configured)" 246 } 247compaction := snap .Compaction 248lines := factRows ("model" ,label ,width ) 249lines = append (lines ,factRows ("window" ,window ,width )... ) 250strategy := cmp .Or (compaction .Strategy ,"none" ) 251if status := compactionStatuses [compaction .Status ];status != "" { 252strategy += " · " + status 253 } 254lines = append (lines ,factRows ("compact" ,strategy ,width )... ) 255if compaction .TriggerFreePercent != nil { 256lines = append (lines ,factRows ("trigger" ,fmt .Sprintf ("keep %g%% free" ,* compaction .TriggerFreePercent ),width )... ) 257 } 258switch { 259case compaction .ProviderInputTokens != nil : 260measured := contextCount (* compaction .ProviderInputTokens ,"token" )+ " from provider" 261if compaction .ProviderCachedInputTokens != nil { 262measured += " · " + contextCount (* compaction .ProviderCachedInputTokens ,"cached token" ) 263 } 264lines = append (lines ,factRows ("input" ,measured ,width )... ) 265case compaction .EstimatedInputTokens != nil : 266method := cmp .Or (compaction .EstimateMethod ,"method not reported" ) 267lines = append (lines ,factRows ("input" ,contextCount (* compaction .EstimatedInputTokens ,"token" )+ " estimated · " + method ,width )... ) 268 } 269return lines 270} 271 272// emptyText says why there are no section rows. 273func (m ContextInspectorModel )emptyText ()string { 274switch { 275case m .Loading && m .Snapshot == nil : 276return "loading prepared request snapshot…" 277case m .Snapshot == nil : 278return "no prepared request to inspect" 279case m .Snapshot .State == "pending" : 280return "sections appear once a request is prepared" 281 } 282return "the prepared request contains no inspectable sections" 283} 284 285// list draws the request's standing above the sections, then the sections, 286// at exactly width × height. 287func (m ContextInspectorModel )list (width ,height int ) []string { 288head := m .requestFacts (width ) 289// The note and the facts give up their rows, in that order, to the 290// sections when the list is short, as it is with the pane stacked below. 291if height >= len (head )+ 8 { 292head = append (head ,paneNote ("read-only · durable transcript and request-only context are separate" ,width )... ) 293head = append (head ,"" ) 294 }else if height < len (head )+ 4 { 295head = nil 296 } 297for i := range head { 298head [i ]= svFit (head [i ],width ) 299 } 300lv := m .listView 301lv .Empty = m .emptyText () 302return append (head ,lv .list (width ,max (1 ,height - len (head )))... ) 303} 304 305func (m ContextInspectorModel )footer (width int )string { 306hints := []hint {{"↑↓" ,"move" }, {"enter" ,"inspect" }, {"ctrl+r" ,"refresh" }, {"esc" ,"back" }} 307var status string 308urgent := true 309switch { 310case m .Error != "" : 311status = DefaultStyles .Error .Render (m .Error ) 312case m .Loading : 313status ,urgent = DefaultStyles .Faint .Render ("loading…" ),false 314case m .Snapshot != nil : 315status ,urgent = DefaultStyles .Faint .Render (counted (len (m .Snapshot .Sections ),"section" )),false 316 } 317return footerLine (width ,hints ,status ,urgent ) 318} 319 320// readerFooter is the footer while a section's content is open. 321func readerFooter (width int )string { 322return footerLine (width , []hint {{"↑↓" ,"scroll" }, {"pgup/pgdn" ,"jump" }, {"←→" ,"page" }, {"esc" ,"sections" }},"" ,false ) 323} 324 325func (m ContextInspectorModel )Update (msg tea.Msg ) (ContextInspectorModel , tea.Cmd ) { 326switch msg := msg .(type ) { 327case contextSnapshotLoadedMsg : 328if ! m .settle (msg .Gen ,msg .Err ,& m .Loading ) { 329return m ,nil 330 } 331m .Snapshot ,m .Error = msg .Snapshot ,"" 332m .setSections () 333return m ,nil 334 335case contextPageLoadedMsg : 336if msg .Gen != m .Generation || m .Detail == nil || m .Detail .Section .ID != msg .SectionID || m .Detail .Page != msg .Page { 337return m ,nil 338 } 339if msg .Err != nil { 340m .Detail .Error = msg .Err .Error () 341 }else { 342m .Detail .Value ,m .Detail .Error = msg .Data ,"" 343 } 344return m ,nil 345 346case tea.KeyPressMsg : 347if m .Detail != nil { 348return m .readerKey (msg ) 349 } 350return m .sectionsKey (msg ) 351 } 352cmd := m .listView .update (msg ) 353return m ,cmd 354} 355 356// readerKey scrolls and pages through a section's content; esc returns to 357// the sections. 358func (m ContextInspectorModel )readerKey (msg tea.KeyPressMsg ) (ContextInspectorModel , tea.Cmd ) { 359d := m .Detail 360switch msg .String () { 361case "esc" ,"ctrl+c" ,"ctrl+d" : 362m .Detail = nil 363m .Generation = nextPageGeneration () 364case "up" : 365d .scroll (- 1 ) 366case "down" : 367d .scroll (1 ) 368case "pgup" : 369d .scroll (- max (1 ,d .visible )) 370case "pgdown" : 371d .scroll (max (1 ,d .visible )) 372case "left" ,"right" : 373page := d .Page - 1 374if msg .String ()== "right" { 375page = d .Page + 1 376 } 377if page >= 0 && page < d .Section .Pages { 378d .Page ,d .Value ,d .Scroll ,d .Error = page ,nil ,0 ,"" 379m .Generation = nextPageGeneration () 380return m ,m .loadPageCmd (d .Section .ID ,page ,m .Generation ) 381 } 382 } 383return m ,nil 384} 385 386func (m ContextInspectorModel )sectionsKey (msg tea.KeyPressMsg ) (ContextInspectorModel , tea.Cmd ) { 387switch msg .String () { 388case "esc" ,"ctrl+c" ,"ctrl+d" : 389return m ,func () tea.Msg {return ContextDoneMsg {} } 390case "enter" : 391if sec ,ok := m .current ();ok && sec .Pages > 0 { 392m .Detail = & ContextDetail {Section :sec } 393m .Generation = nextPageGeneration () 394return m ,m .loadPageCmd (sec .ID ,0 ,m .Generation ) 395 } 396return m ,nil 397case "ctrl+r" : 398m .Loading ,m .Snapshot ,m .Error = true ,nil ,"" 399m .setSections () 400m .Generation = nextPageGeneration () 401return m ,m .loadSnapshotCmd (m .Generation ) 402 } 403cmd := m .listView .update (msg ) 404return m ,cmd 405} 406 407func wrapContextContent (content string ,width int ) []string { 408if width <= 1 { 409width = 76 410 } 411return strings .Split (ansi .Wrap (content ,width ," " ),"\n" ) 412} 413 414var englishPrinter = message .NewPrinter (language .English ) 415 416func contextCount (n int ,unit string )string { 417if n != 1 { 418unit += "s" 419 } 420return englishPrinter .Sprintf ("%d" ,n )+ " " + unit 421} 422 423// reader is the section's content as a list with no filter and no pane. 424func (m ContextInspectorModel )reader ()listFrame { 425title := brand ("albedo" )+ " " + DefaultStyles .Muted .Render ("/context" ) 426return listFrame { 427title :title , 428right :DefaultStyles .Faint .Render (m .Detail .Section .Label ), 429list :m .Detail .list , 430footer :readerFooter (max (1 ,m .Width )), 431 } 432} 433 434func (m ContextInspectorModel )View ()string { 435if m .Detail != nil { 436return m .reader ().view (m .Width ,m .Height ) 437 } 438title := brand ("albedo" )+ " " + DefaultStyles .Muted .Render ("/context" ) 439f := m .listView .frame (title ,DefaultStyles .Faint .Render ("prepared request" ),m .footer (max (1 ,m .Width ))) 440f .list = m .list 441return f .view (m .Width ,m .Height ) 442}