okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
bf7fef9
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"cmp" 6"context" 7"fmt" 8"maps" 9"slices" 10"strings" 11"time" 12 13 tea"charm.land/bubbletea/v2" 14"github.com/charmbracelet/x/ansi" 15) 16 17func (m * ChatModel )syncViewportHeight () { 18if m == nil || m .History == nil || m .Height <= 0 { 19return 20 } 21m .Viewport .SetHeight (max (1 ,m .Height - 6 - m .chromeRows ())) 22m .refreshViewportContent () 23} 24 25func (m * ChatModel )rebuildSettledLines () { 26// Re-rendering retained history must not move the reading position. Keep 27// the content row under the viewport rather than anchoring to the tail. 28readingOffset := m .scrollOffset 29var readingRow string 30if readingOffset >= 0 && readingOffset < len (m .frameLines ) { 31readingRow = m .frameLines [readingOffset ] 32 } 33m .settledLines = nil 34m .settledLinesBytes = 0 35m .droppedSettledLines = 0 36m .userRows = nil 37m .rebuilding = true 38entries := m .History .Entries () 39// Only a reader scrolled up keeps the blocks: each older page rebuilds 40// everything retained, and reuses what the last rebuild rendered. 41var kept map [settledKey ]settledBlock 42if ! m .Follow { 43kept = make (map [settledKey ]settledBlock ,len (entries )) 44 } 45for i := range entries { 46key ,named := m .settledKey (entries [:i ],entries [i ]) 47block ,ok := m .settled [key ] 48if ! named || ! ok { 49block .rows ,block .head = m .Renderer .Settle (entries [:i ],entries [i ],m .Flags ) 50 } 51if named && kept != nil { 52kept [key ]= block 53 } 54m .appendRows (entries [i ],block ) 55 } 56m .settled = kept 57m .rebuilding = false 58m .settledOffset = len (m .headerLines ()) 59m .scrollOffset = readingOffset 60if readingRow != "" { 61rows := append (m .headerLines (),m .settledLines ... ) 62if readingOffset >= len (rows )|| rows [readingOffset ]!= readingRow { 63for i ,row := range rows { 64if row == readingRow { 65m .scrollOffset = i 66break 67 } 68 } 69 } 70 } 71m .trimSettledLines () 72} 73 74// headerLines lead the transcript: where older history stands. Their count 75// only changes with what is loaded, and refreshViewportContent keeps the 76// reading position steady when it does. 77func (m ChatModel )headerLines () []string { 78var line string 79switch { 80case m .loadingOlder : 81line = DefaultStyles .Faint .Render ("↑ loading earlier messages…" ) 82case m .hasOlder (): 83line = DefaultStyles .Faint .Render ("↑ earlier messages load as you scroll up" ) 84case m .History .TruncationNotice ()!= "" : 85line = DefaultStyles .Warning .Render (m .History .TruncationNotice ()) 86default : 87return nil 88 } 89return []string {markChrome + m .Renderer .rail (laneNone )+ line ,"" } 90} 91 92// hasOlder is whether scrolling past the top can show more: rows trimmed 93// from the rendering, or transcript the daemon has not sent. 94func (m ChatModel )hasOlder ()bool { 95_ ,more := m .olderCursor () 96return m .droppedSettledLines > 0 || more 97} 98 99// olderCursor is the row the next older page ends before. Evicted entries 100// move it forward: everything up to the newest evicted row is re-read. 101func (m ChatModel )olderCursor () (int64 ,bool ) { 102before ,more := m .olderBefore ,m .olderMore 103if through := m .History .EvictedThrough ();through > 0 && through >= before { 104before ,more = through + 1 ,true 105 } 106return before ,more && before > 0 107} 108 109// settledBlock is an entry's settled rows, head indexing its own first one. 110type settledBlock struct { 111rows []string 112head int 113} 114 115// settledKey names what an entry's settled rows depend on: the entry, the 116// author and time before it, how it joins the entry above, and the burst it 117// ends, by the IDs at its ends and how it joins the entry above that. 118type settledKey struct { 119workspace string 120id string 121burstFirst string 122burstLast string 123prior prior 124burst int 125epoch int 126width int 127follows lane 128burstFollows lane 129flags DisplayFlags 130separated bool 131burstSeparated bool 132} 133 134// settledKey is false for an entry, or a burst end, without an ID to name. 135func (m ChatModel )settledKey (before []HistoryEntry ,entry HistoryEntry ) (settledKey ,bool ) { 136key := settledKey {id :entry .ID ,prior :priorOf (before ),epoch :m .burstEpoch ,width :m .Renderer .BodyWidth ,workspace :m .Renderer .Workspace ,flags :m .Flags } 137if entry .ID == "" || Compact (entry ,m .Flags ) { 138return key ,false 139 } 140if n := len (before );n > 0 { 141key .follows ,key .separated = laneOf (before [n - 1 ]),Separated (& before [n - 1 ],entry ,m .Flags ) 142 } 143burst := trailingBurst (before ,m .Flags ) 144if len (burst )== 0 { 145return key ,true 146 } 147key .burstFirst ,key .burstLast ,key .burst = burst [0 ].ID ,burst [len (burst )- 1 ].ID ,len (burst ) 148if n := len (before )- len (burst );n > 0 { 149key .burstFollows ,key .burstSeparated = laneOf (before [n - 1 ]),Separated (& before [n - 1 ],burst [0 ],m .Flags ) 150 } 151return key ,key .burstFirst != "" && key .burstLast != "" 152} 153 154// appendBlock settles entry after the entries before it. 155func (m * ChatModel )appendBlock (before []HistoryEntry ,entry HistoryEntry ) { 156var block settledBlock 157block .rows ,block .head = m .Renderer .Settle (before ,entry ,m .Flags ) 158m .appendRows (entry ,block ) 159} 160 161func (m * ChatModel )appendRows (entry HistoryEntry ,block settledBlock ) { 162if entry .Kind == EntryUser && (entry .Source == "" || entry .Source == "chat" ) { 163m .userRows = append (m .userRows ,len (m .settledLines )+ block .head ) 164 } 165for _ ,row := range block .rows { 166m .settledLines = append (m .settledLines ,row ) 167m .settledLinesBytes += int64 (len (row )) 168 } 169m .trimSettledLines () 170} 171 172// trimSettledLines drops the oldest rows past the caps. While you are scrolled 173// up the caps stretch by readingSlack and the row you are reading is never 174// dropped, so output arriving meanwhile cannot push your place off the top; 175// the normal caps apply again once the transcript follows its end. 176func (m * ChatModel )trimSettledLines () { 177if m .rebuilding { 178return 179 } 180maxLines ,maxBytes := MaxSettledLines ,int64 (MaxSettledLinesBytes ) 181if ! m .Follow { 182maxLines ,maxBytes = maxLines * readingSlack ,maxBytes * readingSlack 183 } 184dropped := 0 185for len (m .settledLines )> maxLines || m .settledLinesBytes > maxBytes { 186if len (m .settledLines )== 0 || ! m .Follow && dropped >= m .scrollOffset - m .settledOffset { 187break 188 } 189removed := m .settledLines [0 ] 190m .settledLinesBytes -= int64 (len (removed )) 191m .settledLines [0 ]= "" 192m .settledLines = m .settledLines [1 :] 193dropped ++ 194 } 195 196if dropped > 0 { 197m .droppedSettledLines += dropped 198m .scrollOffset = max (0 ,m .scrollOffset - dropped ) 199kept := m .userRows [:0 ] 200for _ ,row := range m .userRows { 201if row >= dropped { 202kept = append (kept ,row - dropped ) 203 } 204 } 205m .userRows = kept 206// Rendered rows own their text: body chunks are concatenated with a 207// nonempty rail; separators retain only small rail strings or empty text. 208fresh := make ([]string ,len (m .settledLines )) 209copy (fresh ,m .settledLines ) 210clear (m .settledLines ) 211m .settledLines = fresh 212 } 213} 214 215// replaceSettled swaps the n settled rows at from for rows, and returns how 216// many rows the caps then dropped from the top. 217func (m * ChatModel )replaceSettled (from ,n int ,rows []string )int { 218m .settledLinesBytes += rowBytes (rows )- rowBytes (m .settledLines [from :from + n ]) 219m .settledLines = slices .Replace (m .settledLines ,from ,from + n ,rows ... ) 220for i ,at := range m .userRows { 221if at >= from + n { 222m .userRows [i ]+= len (rows )- n 223 } 224 } 225dropped := m .droppedSettledLines 226m .trimSettledLines () 227return m .droppedSettledLines - dropped 228} 229 230func rowBytes (rows []string )int64 { 231var n int64 232for _ ,row := range rows { 233n += int64 (len (row )) 234 } 235return n 236} 237 238func (m * ChatModel )appendSettledEntry (entry HistoryEntry ) { 239if Compact (entry ,m .Flags ) { 240facts := factsOf (entry ) 241entry .facts = & facts 242 } 243if m .History .Replace (entry ) { 244m .burstEpoch ++ 245m .rebuildSettledLines () 246return 247 } 248m .appendBlock (m .History .Entries (),entry ) 249m .History .Append (entry ) 250} 251 252// settleActiveStream appends assembled text and retains a live thought's last 253// line as the compact action until the next action replaces it. 254func (m * ChatModel )settleActiveStream () { 255watched := ! m .transcript .thinkingSince .IsZero () 256for _ ,entry := range m .transcript .settle (m .AgentName ) { 257if watched && entry .Kind == EntryThinking { 258m .ThoughtProgressText = cmp .Or (thinkingLine (entry .Text ),"thinking" ) 259 } 260m .appendSettledEntry (entry ) 261 } 262} 263 264func (m * ChatModel )refreshViewportContent ()int { 265if m .Viewport .Height ()!= max (1 ,m .Height - 6 - m .chromeRows ()) { 266m .SetSize (m .Width ,m .Height ) 267return m .scrollLimit 268 } 269if m .Follow { 270// Reading older history stretched both caps; at the end they return. 271m .History .Trim () 272m .trimSettledLines () 273m .settled = nil 274 } 275allLines := m .headerLines () 276if ! m .Follow { 277// a header growing or shrinking above must not move what you read 278m .scrollOffset = max (0 ,m .scrollOffset + len (allLines )- m .settledOffset ) 279 } 280m .settledOffset = len (allLines ) 281allLines = append (allLines ,m .settledLines ... ) 282 283last ,stacks := laneNone ,false 284if entries := m .History .Entries ();len (entries )> 0 { 285last = laneOf (entries [len (entries )- 1 ]) 286stacks = Compact (entries [len (entries )- 1 ],m .Flags ) 287if burst := trailingBurst (entries ,m .Flags );len (burst )> 0 { 288if key := m .rowsKey ();key != m .burstRowsKey { 289m .burstRows = m .Renderer .BurstBlock (entries [:len (entries )- len (burst )],burst ,m .Flags ) 290m .burstRowsKey = key 291m .burstRenders ++ 292 } 293allLines = append (allLines ,m .burstRows ... ) 294 } 295 } 296if m .transcript .activeKind != StreamKindNone && m .transcript .activeText != "" && 297 (m .transcript .activeKind != StreamKindThinking || m .Flags .Thinking ) { 298activeEntry := HistoryEntry {Kind :m .transcript .activeEntryKind (),Speaker :m .AgentName ,Text :m .transcript .activeText ,Live :true } 299allLines = append (allLines ,m .liveBlock (activeEntry )... ) 300last ,stacks = laneOf (activeEntry ),false 301 } 302 303// One live-tail slot stays occupied by the last action until another begins. 304action := "" 305switch { 306case m .transcript .activeKind == StreamKindThinking && m .transcript .activeText != "" && ! m .Flags .Thinking : 307action = m .renderThought () 308case m .toolLabel ()!= "" && (m .latestProgress ()!= nil || ! m .Flags .Tools ): 309action = m .renderProgress () 310case m .ThoughtProgressText != "" && ! m .Flags .Thinking : 311width := max (1 ,m .Renderer .BodyWidth - railWidth ) 312action = markChrome + DefaultStyles .Faint .Render (ansi .Truncate (m .ThoughtProgressText ,width ,"…" )) 313 } 314if action != "" { 315if len (allLines )> 0 && ! stacks { 316allLines = append (allLines ,strings .TrimRight (m .Renderer .rail (joint (last ,laneBusy ))," " )) 317 } 318allLines = append (allLines ,m .Renderer .rail (laneBusy )+ action ) 319 } 320allLines = append (allLines ,m .pendingRows ()... ) 321m .frameLines = allLines 322 323totalLines := len (allLines ) 324vpHeight := max (1 ,m .Viewport .Height ()) 325maxScroll := max (0 ,totalLines - vpHeight ) 326m .scrollLimit = maxScroll 327 328var visibleSlice []string 329if m .Follow { 330m .scrollOffset = maxScroll 331visibleSlice = allLines [maxScroll :totalLines ] 332 }else { 333m .scrollOffset = max (0 ,min (m .scrollOffset ,maxScroll )) 334end := min (totalLines ,m .scrollOffset + vpHeight ) 335if m .scrollOffset < end { 336visibleSlice = allLines [m .scrollOffset :end ] 337 } 338 } 339m .Viewport .rows = visibleSlice 340return maxScroll 341} 342 343// rowsKey names what rows drawn after the settled transcript depend on. 344func (m ChatModel )rowsKey ()burstRowsKey { 345return burstRowsKey {entries :m .History .Len (),evicted :m .History .EvictedCount (),epoch :m .burstEpoch ,width :m .Renderer .BodyWidth ,workspace :m .Renderer .Workspace ,flags :m .Flags } 346} 347 348// liveBlock is the reply streaming in as rows, rendered again only when the 349// reply or the transcript end before it changed. 350func (m * ChatModel )liveBlock (entry HistoryEntry ) []string { 351key := liveRowsKey {burstRowsKey :m .rowsKey (),text :entry .Text ,kind :entry .Kind } 352if key != m .liveRowsKey { 353start := time .Now () 354m .liveRows ,_ = m .Renderer .Block (m .History .Entries (),entry ,m .Flags ) 355m .liveRowsKey ,m .liveDrawn = key ,start 356m .liveInterval = max (liveFrame ,4 * time .Since (start )) 357 } 358return m .liveRows 359} 360 361// pendingRows are your messages the daemon has not echoed yet, greyed out at 362// the end of the transcript where they will settle. Each follows whatever is 363// live above it, so the rails and names join up as they will once settled. 364func (m ChatModel )pendingRows () []string { 365if len (m .pendingUsers )== 0 && len (m .pendingContinuations )== 0 { 366return nil 367 } 368before := slices .Clone (recent (m .History .Entries ())) 369if m .transcript .activeKind != StreamKindNone && m .transcript .activeText != "" { 370before = append (before ,HistoryEntry {Kind :m .transcript .activeEntryKind (),Speaker :m .AgentName }) 371 } 372if m .toolLabel ()!= "" && (m .latestProgress ()!= nil || ! m .Flags .Tools ) { 373before = append (before ,HistoryEntry {Kind :EntryTool ,Speaker :m .AgentName }) 374 }else if m .ThoughtProgressText != "" && ! m .Flags .Thinking { 375before = append (before ,HistoryEntry {Kind :EntryThinking ,Speaker :m .AgentName }) 376 } 377var rows []string 378for _ ,p := range m .pendingUsers { 379state := sending 380if p .Queued { 381state = queued 382 } 383text := p .Text 384if p .Expired { 385state = unresolved 386text += "\nOperation " + p .OperationID + " expired; its outcome is unknown." 387 } 388if p .BlockingReason != "" && ! p .Expired { 389text += "\nWaiting: " + p .BlockingReason 390 } 391entry := HistoryEntry {Kind :EntryUser ,Speaker :"You" ,Text :text ,Timestamp :p .At ,Pending :state } 392block ,_ := m .Renderer .Block (before ,entry ,m .Flags ) 393rows = append (rows ,block ... ) 394before = append (before ,entry ) 395 } 396for _ ,id := range slices .Sorted (maps .Keys (m .pendingContinuations )) { 397if m .pendingContinuations [id ].Expired { 398entry := HistoryEntry {Kind :EntryError ,Text :"Continue operation " + id + " expired; its outcome is unresolved." } 399block ,_ := m .Renderer .Block (before ,entry ,m .Flags ) 400rows = append (rows ,block ... ) 401 } 402 } 403return rows 404} 405 406// jumpToYou scrolls to the start of your previous or next message. Past 407// your newest message it follows the live transcript again. 408func (m * ChatModel )jumpToYou (back bool ) { 409at := m .scrollOffset 410if m .Follow { 411at = m .scrollLimit 412 } 413target := - 1 414for _ ,row := range m .userRows { 415row += m .settledOffset 416if back && row < at { 417target = row 418 } 419if ! back && row > at { 420target = row 421break 422 } 423 } 424switch { 425case target >= 0 && target < m .scrollLimit : 426m .Follow = false 427m .scrollOffset = target 428case ! back || target >= 0 : 429m .Follow = true 430 } 431m .refreshViewportContent () 432} 433 434// scrollBy moves through the complete rendered transcript, including live output. 435func (m * ChatModel )scrollBy (rows int ) { 436if rows < 0 { 437m .Follow = false 438 }else if m .Follow { 439return 440 } 441m .scrollOffset = max (0 ,m .scrollOffset + rows ) 442maxScroll := m .refreshViewportContent () 443if rows > 0 && m .scrollOffset >= maxScroll { 444m .Follow = true 445m .refreshViewportContent () 446 } 447} 448 449// loadOlder runs when you are scrolled to the top of the transcript: rows 450// trimmed from the rendering come back from retained history first, then a 451// page is asked of the daemon. 452func (m * ChatModel )loadOlder () tea.Cmd { 453if m .Follow || m .scrollOffset > 0 || m .loadingOlder { 454return nil 455 } 456if m .droppedSettledLines > 0 { 457m .rebuildSettledLines () 458m .refreshViewportContent () 459return nil 460 } 461before ,more := m .olderCursor () 462if ! more { 463return nil 464 } 465m .loadingOlder = true 466m .refreshViewportContent () 467client ,id ,generation := m .client ,m .SessionID ,m .Generation 468return func () tea.Msg { 469page ,err := client .History (context .Background (),before ,olderPageRows ) 470return ChatOlderLoadedMsg {SessionID :id ,Generation :generation ,Page :page ,Err :err } 471 } 472} 473 474// showOlder puts a fetched page above what is shown without moving it. 475func (m * ChatModel )showOlder (msg ChatOlderLoadedMsg )ChatModel { 476if msg .SessionID != m .SessionID || msg .Generation != m .Generation || ! m .loadingOlder { 477return * m 478 } 479m .loadingOlder = false 480if msg .Err != nil { 481m .AddError (fmt .Sprintf ("Could not load earlier messages: %v" ,msg .Err )) 482return * m 483 } 484events := make ([]daemon.StreamEvent ,0 ,len (msg .Page .Events )) 485for _ ,event := range msg .Page .Events { 486if event .EntryID == "" || m .History .Find (event .EntryID )< 0 { 487events = append (events ,event ) 488 } 489 } 490older := replayTranscript (events ,m .AgentName ) 491for i := range older { 492if Compact (older [i ],m .Flags ) { 493facts := factsOf (older [i ]) 494older [i ].facts = & facts 495 } 496 } 497fromEnd := m .settledOffset + len (m .settledLines )- m .scrollOffset 498m .History .Prepend (older ) 499m .olderBefore ,m .olderMore = msg .Page .Before ,msg .Page .More && msg .Page .Before > 0 500m .rebuildSettledLines () 501m .scrollOffset = max (0 ,m .settledOffset + len (m .settledLines )- fromEnd ) 502m .refreshViewportContent () 503return * m 504}