okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
cc6ab84
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"context" 6"fmt" 7"path/filepath" 8"regexp" 9"strings" 10"time" 11 12"charm.land/bubbles/v2/textarea" 13 tea"charm.land/bubbletea/v2" 14"charm.land/lipgloss/v2" 15) 16 17type ActiveStreamKind string 18 19const ( 20StreamKindNone ActiveStreamKind = "" 21StreamKindText ActiveStreamKind = "text" 22StreamKindThinking ActiveStreamKind = "thinking" 23) 24 25type pendingOperation struct { 26Handle * daemon.OperationHandle 27Expired bool 28} 29 30type PendingUserTurn struct { 31Expired bool 32Handle * daemon.OperationHandle 33OperationID string 34BlockingReason string 35Images []daemon.ImageAttachment 36Pastes []string 37Text string 38At int64 39Queued bool 40} 41 42const ( 43MaxLiveStreamBytes = 64 * 1024 44MaxPendingUsers = 10 45MaxSettledLines = 1000 46MaxSettledLinesBytes = 256 * 1024 47// readingSlack multiplies the caps while you are scrolled up, so output 48// arriving as you read does not shrink the transcript above you. 49readingSlack = 8 50// olderPageRows is how many transcript rows a reset replays and each 51// older page adds; the daemon widens both back to a turn's start. 52olderPageRows = 120 53fnvOffset64 = 14695981039346656037 54fnvPrime64 = 1099511628211 55) 56 57func fnv1a (h uint64 ,s string )uint64 { 58for i := 0 ;i < len (s );i ++ { 59h ^= uint64 (s [i ]) 60h *= fnvPrime64 61 } 62return h 63} 64 65var ( 66sgrCode = regexp .MustCompile (`\x1b\[[0-9;]*m` ) 67phaseResting = daemon .PhaseResting 68phaseReasoning = daemon .PhaseReasoning 69phaseTool = daemon .PhaseTool 70) 71 72// burstRowsKey is what the cached live burst rows were rendered for: which 73// entries end the transcript (count and evictions, since an eviction swaps the 74// head without changing the count), how wide they render, where paths are 75// named from, and which of them group at all. 76type burstRowsKey struct { 77workspace string 78entries int 79evicted int 80epoch int 81width int 82flags DisplayFlags 83} 84 85// liveRowsKey is what the cached rows of the reply streaming in were 86// rendered for: the same transcript end as a burst, and the reply itself. 87type liveRowsKey struct { 88burstRowsKey 89text string 90kind EntryKind 91} 92 93// liveFrame is the shortest wait between two renderings of a growing reply. 94const liveFrame = 33 * time .Millisecond 95 96type ChatModel struct { 97streamCtx context.Context 98Usage * daemon.Usage 99progressByCallID map [string ]* daemon.ToolProgress 100progressOrder []string 101eventChan chan streamDelivery 102streamStopped bool 103// Images and Pastes are what was pasted into the composer as markers. 104Images promptImages 105Pastes promptPastes 106// window is the context window of windowModel, read once per model so 107// the footer can say how full the context is. 108window * int 109client * daemon.ChatClient 110History * BoundedHistory 111 112// Selection coordinates use frameLines, the whole transcript as last 113// drawn, so a selection can extend beyond the viewport. 114dragAnchor * Point 115streamCancel context.CancelFunc 116windowModel * string 117// stretch is the phase run the status face animates. 118stretch stretch 119SessionID string 120Status daemon.AgentStatus 121AgentName string 122Workspace string 123Host string // the label of a remote workspace's host; "" when local 124Model string 125Effort string 126Provider string 127CopyStatus string 128settledToolLabel string 129ThoughtProgressText string 130Renderer TranscriptRenderer 131 132// hostHome is the remote home ~ names in the header, once the host's 133// probe reported it for hostAsked, the workspace it was asked for. 134hostHome ,hostAsked string 135 136// userRows are the settled rows where user messages start. 137userRows []int 138effortOptions []string 139 140// burstRows is the rendered frame of the trailing run of compact entries. 141// Its key names every input the rows depend on, so a refresh that changes 142// nothing redraws nothing, and nothing can ask for a stale frame. 143burstRows []string 144// liveRows is the reply streaming in, rendered whole. While only its 145// text grows it is rendered again at most once per liveInterval, a few 146// times what the last rendering took, so a long reply cannot spend the 147// whole stream re-rendering itself; liveDue is a later redraw on its way. 148// settled is each named entry's rows from the last rebuild while reading 149// older history; see rebuildSettledLines. 150settled map [settledKey ]settledBlock 151liveRows []string 152liveRowsKey liveRowsKey 153liveDrawn time.Time 154liveInterval time.Duration 155liveDue bool 156Notices Notices 157settledLines []string 158 159pendingUsers []PendingUserTurn 160pendingContinuations map [string ]pendingOperation 161Glances []PageGlance 162// layoutSidebar is the sidebar width the last layout reserved. 163layoutSidebar int 164frameLines []string 165CommandMenu CommandMenuModel 166Viewport pane 167 168transcript transcriptState 169TextArea textarea.Model 170burstRowsKey burstRowsKey 171dragHead Point 172effortSelected int 173Generation int64 174terminalClipboard terminalClipboardRead 175copyStatusRevision uint64 176burstEpoch int 177// burstRenders counts frame builds; tests watch it to catch a cache that 178// redraws every refresh or misses an invalidation. 179burstRenders int 180ProgressFrame int 181Width int 182// moodSeed selects the status-face animation for the current stretch. 183moodSeed int64 184Height int 185droppedSettledLines int 186settledLinesBytes int64 187statusRevision uint64 188statusPoll uint64 189// statusPollResting is set while the next status poll is the slow one. 190statusPollResting bool 191scrollOffset int 192 193scrollLimit int 194dragDir int 195dragCtrl bool 196dragGen int 197// olderBefore is the first transcript row carried by the reset or last older page. 198olderBefore int64 199// settledOffset counts notice rows above the settled transcript. 200settledOffset int 201Flags DisplayFlags 202Follow bool 203TurnFailed bool 204Stopping bool 205Stopped bool 206animationActive bool 207// rebuilding defers trimming until a re-render has placed the reading position. 208rebuilding bool 209 210// olderMore reports whether transcript rows precede olderBefore. 211olderMore bool 212// loadingOlder prevents overlapping history fetches. 213loadingOlder bool 214isSending bool 215interruptDeferred bool 216hostAuth * hostSignIn 217sentHere bool 218// graphemes says the terminal measures grapheme clusters (mode 2027), 219// which Bubble Tea turns off when it hands the terminal to the editor. 220graphemes bool 221} 222 223// NewChatModel requires a session client. A nil client panics. 224func NewChatModel (session * daemon.Session ,client * daemon.ChatClient )ChatModel { 225if client == nil { 226panic ("tui.NewChatModel: nil client" ) 227 } 228ta := textarea .New () 229ta .Placeholder = "" 230ta .Prompt = promptMark 231ta .CharLimit = 0 232ta .ShowLineNumbers = false 233ta .SetPromptFunc (promptMarkWidth ,func (info textarea.PromptInfo )string { 234if info .LineNumber == 0 { 235return promptMark 236 } 237return strings .Repeat (" " ,promptMarkWidth ) 238 }) 239ta .KeyMap .InsertNewline .SetKeys ("enter" ,"ctrl+m" ,"alt+enter" ,"shift+enter" ) 240ta .KeyMap .WordBackward .SetKeys ("alt+left" ,"alt+b" ,"ctrl+left" ) 241ta .KeyMap .WordForward .SetKeys ("alt+right" ,"alt+f" ,"ctrl+right" ) 242ta .SetHeight (6 ) 243st := ta .Styles () 244st .Focused .Prompt ,st .Blurred .Prompt = DefaultStyles .Prompt ,DefaultStyles .Prompt 245st .Focused .CursorLine ,st .Blurred .CursorLine = lipgloss .NewStyle (),lipgloss .NewStyle () 246st .Focused .Placeholder ,st .Blurred .Placeholder = DefaultStyles .Faint ,DefaultStyles .Faint 247st .Cursor .Blink ,st .Cursor .Color = false ,nil 248ta .SetStyles (st ) 249ta .Focus () 250 251vp := pane {width :80 ,height :20 } 252 253bh := NewBoundedHistory (500 ,2 * 1024 * 1024 ) 254 255ctx ,cancel := context .WithCancel (context .Background ()) 256gen := time .Now ().UnixNano () 257 258m := ChatModel { 259SessionID :session .ID , 260Generation :gen , 261AgentName :"albedo" , 262Workspace :session .Workspace , 263Host :sessionHost (* session ), 264Model :session .Model , 265Effort :session .Effort , 266Provider :session .Provider , 267Glances :session .Glances , 268client :client , 269History :bh , 270Renderer :TranscriptRenderer {Workspace :session .Workspace }, 271Viewport :vp , 272TextArea :ta , 273CommandMenu :NewCommandMenuModel (), 274Flags :DisplayFlags {}, 275Follow :true , 276transcript :newTranscriptState (), 277streamCtx :ctx , 278streamCancel :cancel , 279eventChan :make (chan streamDelivery ,16 ), 280 } 281 282return m 283} 284 285func (m * ChatModel )Close () { 286m .terminalClipboard = terminalClipboardRead {} 287if m .streamCancel != nil { 288m .streamCancel () 289 } 290} 291 292func (m * ChatModel )AddNotice (message string ) { 293if m .Notices .AddNotice (message ) { 294m .syncViewportHeight () 295 } 296} 297 298func (m * ChatModel )AddError (message string ) { 299if m .Notices .AddError (message ) { 300m .syncViewportHeight () 301 } 302} 303 304func (m * ChatModel )ClearNotices () { 305// A successful mutation does not repair a stopped subscription. 306if m .streamStopped { 307return 308 } 309if len (m .Notices )> 0 { 310m .Notices .Clear () 311m .syncViewportHeight () 312 } 313} 314 315// Update handles msg, then fetches older history when it left you at the top. 316func (m ChatModel )Update (msg tea.Msg ) (ChatModel , tea.Cmd ) { 317if loaded ,ok := msg .(ChatOlderLoadedMsg );ok { 318return m .showOlder (loaded ),nil 319 } 320m ,cmd := m .update (msg ) 321if older := m .loadOlder ();older != nil { 322return m ,tea .Batch (cmd ,older ) 323 } 324return m ,cmd 325} 326 327func (m ChatModel )update (msg tea.Msg ) (ChatModel , tea.Cmd ) { 328if cmd ,handled := m .handleOperationResults (msg );handled { 329return m ,cmd 330 } 331if cmd ,handled := m .handleInput (msg );handled { 332return m ,cmd 333 } 334var cmds []tea.Cmd 335 336switch msg := msg .(type ) { 337 338case ChatStatusPollMsg : 339if msg .SessionID != m .SessionID || msg .Generation != m .Generation || msg .Poll != m .statusPoll || m .streamStopped { 340return m ,nil 341 } 342return m ,m .statusCmd () 343 344case ChatStatusMsg : 345if msg .SessionID != m .SessionID || msg .Generation != m .Generation || m .streamStopped { 346return m ,nil 347 } 348if msg .Err == nil && msg .Status != nil && msg .Revision == m .statusRevision { 349previousChrome := m .chromeRows () 350m .Status = * msg .Status 351m .Glances = msg .Glances 352if m .sidebarWidth ()!= m .layoutSidebar { 353m .SetSize (m .Width ,m .Height ) 354 }else if m .chromeRows ()!= previousChrome { 355m .syncViewportHeight () 356 } 357defer m .reseedMood () 358// most polls find the session idle with nothing live to settle 359live := m .latestProgress ()!= nil || m .settledToolLabel != "" || m .ThoughtProgressText != "" || m .transcript .activeKind != StreamKindNone || m .transcript .turn != nil && m .transcript .turn .begun () 360if live && (! m .Status .Running || m .Status .Idle ) { 361m .settleActiveStream () 362m .clearAction () 363if m .transcript .turn != nil && m .transcript .turn .begun () { 364for _ ,entry := range m .transcript .closeTurn (false ) { 365m .appendSettledEntry (entry ) 366 } 367 } 368m .refreshViewportContent () 369 } 370m .reconcileStopped () 371 } 372home := m .hostHomeCmd () 373return m ,tea .Batch (m .startAnimation (),m .statusPollCmd (),home ) 374 375case ChatHostMsg : 376if msg .SessionID == m .SessionID && msg .Generation == m .Generation && msg .Workspace == m .Workspace && msg .Err == nil { 377_ ,m .hostHome = daemon .SplitLocation (msg .Status .Home ) 378 } 379return m ,nil 380 381case ChatStreamEventMsg : 382if msg .SessionID != m .SessionID || msg .Generation != m .Generation || m .streamStopped { 383return m ,nil 384 } 385var wake tea.Cmd 386// only a live event outdates a status reply in flight 387if ! msg .Event .Replayed { 388m .statusRevision ++ 389// a resting poll would learn of the new activity seconds late 390if m .statusPollResting { 391m .statusPollResting = false 392wake = m .statusCmd () 393 } 394 } 395if msg .Event .Type == daemon .EventInvalidate { 396return m ,tea .Batch (m .waitForNextEvent (),wake ) 397 } 398before ,entries := m .transcript ,m .History .Len () 399m .handleStreamEvent (msg .Event ) 400var draw tea.Cmd 401if m .onlyGrew (before ,entries )&& time .Since (m .liveDrawn )< m .liveInterval { 402draw = m .drawLiveLater () 403 }else { 404m .refreshViewportContent () 405 } 406 407var fade ,window tea.Cmd 408if msg .Event .Type == daemon .EventUsage { 409fade ,window = m .cacheFadeCmd (),m .windowCmd () 410 } 411var reconcile []tea.Cmd 412if msg .Event .Type == daemon .EventReset { 413for _ ,pending := range m .pendingUsers { 414if pending .Handle != nil && ! pending .Expired { 415reconcile = append (reconcile ,m .queryOperationCmd (pending .Handle )) 416 } 417 } 418for _ ,pending := range m .pendingContinuations { 419if pending .Handle != nil && ! pending .Expired { 420reconcile = append (reconcile ,m .queryOperationCmd (pending .Handle )) 421 } 422 } 423 } 424reconcile = append (reconcile ,m .waitForNextEvent (),m .startAnimation (),window ,fade ,wake ,draw ) 425return m ,tea .Batch (reconcile ... ) 426 427case ChatLiveDrawMsg : 428if msg .SessionID == m .SessionID && msg .Generation == m .Generation { 429m .liveDue = false 430m .refreshViewportContent () 431 } 432return m ,nil 433 434case ChatWindowMsg : 435if msg .SessionID == m .SessionID && msg .Generation == m .Generation && msg .Err == nil { 436m .window ,m .windowModel = msg .Tokens ,& msg .Model 437 } 438return m ,nil 439 440case ChatCacheFadeMsg : 441// newer usage has its own countdown 442if msg .SessionID != m .SessionID || msg .Generation != m .Generation || msg .Usage != m .Usage { 443return m ,nil 444 } 445return m ,m .cacheFadeCmd () 446 447case FileOpenResultMsg : 448if msg .SessionID == m .SessionID && msg .Err != nil { 449m .AddError (fmt .Sprintf ("could not open %s: %v" ,filepath .Base (msg .Target .Path ),msg .Err )) 450 } 451return m ,nil 452 453case ChatClearCopyStatusMsg : 454if msg .SessionID == m .SessionID && msg .Generation == m .Generation && msg .Revision == m .copyStatusRevision { 455m .CopyStatus = "" 456 } 457return m ,nil 458 459case ChatProgressTickMsg : 460if msg .SessionID != m .SessionID || msg .Generation != m .Generation || m .streamStopped { 461return m ,nil 462 } 463if ! m .animating () { 464m .animationActive = false 465return m ,nil 466 } 467// the face lives in the status line, so the transcript stays as it is 468m .ProgressFrame ++ 469return m ,m .progressTickCmd () 470 471case ChatStreamResultMsg : 472if msg .SessionID != m .SessionID || msg .Generation != m .Generation || m .streamStopped { 473return m ,nil 474 } 475m .clearAction () 476if msg .Recovering { 477m .AddNotice (fmt .Sprintf ("Stream data could not be read; refreshing history: %v" ,msg .Err )) 478return m ,m .waitForNextEvent () 479 } 480m .streamStopped = true 481m .animationActive = false 482m .AddError (fmt .Sprintf ("Session stream stopped: %v. Reopen the session to reconnect." ,msg .Err )) 483m .refreshViewportContent () 484return m ,nil 485 486case ChatStreamClosedMsg : 487if msg .SessionID == m .SessionID && msg .Generation == m .Generation { 488m .clearAction () 489m .refreshViewportContent () 490 } 491return m ,nil 492 493case chatHostAuthMsg : 494m .offerSignIn (msg ) 495return m ,nil 496 497case chatSignedInMsg : 498m .signedIn (msg ) 499return m ,nil 500 501 } 502 503return m ,m .updateComposer (msg ,cmds ) 504}