okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
bf7fef9
main
1// Scroll follow and reading position across streaming, truncation, and thinking are TUI-only state. 2package tui 3 4import ( 5"fmt" 6"strings" 7"testing" 8"time" 9 10"albedo/cli/internal/daemon" 11 tea"charm.land/bubbletea/v2" 12"github.com/charmbracelet/x/ansi" 13) 14 15func newTestChatModel (t * testing.T ,session * daemon.Session )ChatModel { 16t .Helper () 17conn := daemon .NewConnection (daemon.ConnectionSnapshot {Port :1 },nil ) 18client := daemon .NewChatClient (conn ,session .ID ) 19model := NewChatModel (session ,client ) 20t .Cleanup (model .Close ) 21return model 22} 23 24func TestScrollDoesNotResumeFollowBeforeEndOfLiveOutput (t * testing.T ) { 25for _ ,down := range []struct { 26msg tea.Msg 27name string 28step int 29 }{ 30 {name :"arrow" ,msg : tea.KeyPressMsg {Code :tea .KeyDown },step :1 }, 31 {name :"page" ,msg : tea.KeyPressMsg {Code :tea .KeyPgDown },step :13 }, 32 {name :"wheel" ,msg : tea.MouseWheelMsg {Button :tea .MouseWheelDown },step :3 }, 33 } { 34t .Run (down .name ,func (t * testing.T ) { 35m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 36m .SetSize (80 ,20 ) 37m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :strings .Repeat ("settled\n" ,25 )}) 38m .handleStreamEvent (daemon.StreamEvent {Type :daemon .EventText ,Text :strings .Repeat ("live\n" ,55 )}) 39m .refreshViewportContent () 40bottom := m .scrollOffset 41// This is beyond the end of settled lines, but far from the end of live output. 42m .Follow = false 43m .scrollOffset = len (m .settledLines ) 44m .refreshViewportContent () 45before := m .scrollOffset 46m ,_ = m .Update (down .msg ) 47if m .Follow || m .scrollOffset != before + down .step || m .scrollOffset >= bottom { 48t .Fatalf ("jumped to tail: before=%d after=%d bottom=%d follow=%v" ,before ,m .scrollOffset ,bottom ,m .Follow ) 49 } 50m .refreshViewportContent () 51if m .scrollOffset != before + down .step { 52t .Fatal ("next refresh lost scroll position" ) 53 } 54m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyUp }) 55if m .scrollOffset != before + down .step - 1 || m .Follow { 56t .Fatalf ("up did not move one row: offset=%d follow=%v" ,m .scrollOffset ,m .Follow ) 57 } 58m .scrollBy (1000 ) 59if ! m .Follow || m .scrollOffset != bottom { 60t .Fatalf ("tail not restored at actual end: offset=%d bottom=%d" ,m .scrollOffset ,bottom ) 61 } 62 }) 63 } 64} 65 66func TestReadingPositionSurvivesIncomingTranscript (t * testing.T ) { 67for _ ,scroll := range []struct { 68msg tea.Msg 69name string 70 }{ 71 {name :"keyboard" ,msg : tea.KeyPressMsg {Code :tea .KeyPgUp }}, 72 {name :"wheel" ,msg : tea.MouseWheelMsg {Button :tea .MouseWheelUp }}, 73 } { 74t .Run (scroll .name ,func (t * testing.T ) { 75m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 76m .SetSize (80 ,20 ) 77var text strings.Builder 78for i := range MaxSettledLines + 50 { 79fmt .Fprintf (& text ,"row %d\n" ,i ) 80 } 81m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :text .String ()}) 82m .refreshViewportContent () 83m ,_ = m .Update (scroll .msg ) 84if m .Follow { 85t .Fatal ("scrolling up did not pause following" ) 86 } 87before := m .scrollOffset 88visible ,_ ,_ := strings .Cut (ansi .Strip (m .Viewport .View ()),"\n" ) 89m .AddNotice ("new activity" ) 90m ,_ = m .Update (ChatStreamEventMsg {SessionID :m .SessionID ,Generation :m .Generation ,Event : daemon.StreamEvent {Type :daemon .EventNote ,Text :"a new line" }}) 91after ,_ ,_ := strings .Cut (ansi .Strip (m .Viewport .View ()),"\n" ) 92if m .Follow || after != visible { 93t .Fatalf ("reading position changed: offset %d -> %d, follow=%v" ,before ,m .scrollOffset ,m .Follow ) 94 } 95 }) 96 } 97} 98 99// A collapsed thought follows its newest line and keeps it after settling, 100// until the next action replaces it; earlier lines stay hidden. 101func TestFragmentedThinkingStaysCollapsed (t * testing.T ) { 102m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 103m .SetSize (80 ,20 ) 104for _ ,step := range []struct {text ,want ,hidden string }{ 105 {"**Preparing resize/status tests**" ,"Preparing resize/status tests…" ,"Verifying" }, 106 {"\n" ,"Preparing resize/status tests…" ,"Verifying" }, 107 {"**Verifying scroll" ,"Verifying scroll…" ,"Preparing" }, 108 {" behavior**\n\n" ,"Verifying scroll behavior…" ,"Preparing" }, 109 } { 110m .handleStreamEvent (daemon.StreamEvent {Type :daemon .EventThinking ,Text :step .text }) 111m .refreshViewportContent () 112visible := ansi .Strip (m .Viewport .View ()) 113if ! strings .Contains (visible ,step .want )|| strings .Contains (visible ,step .hidden )|| strings .Contains (visible ,"**" ) { 114t .Fatalf ("thought did not follow its newest line: %q" ,visible ) 115 } 116 } 117m .settleActiveStream () 118m .refreshViewportContent () 119if visible := ansi .Strip (m .Viewport .View ());! strings .Contains (visible ,"Verifying scroll behavior" )|| strings .Contains (visible ,"Preparing" )|| strings .Contains (visible ,"**" )|| ! strings .Contains (visible ,"thought" ) { 120t .Fatalf ("settled thought lost its latest line: %q" ,visible ) 121 } 122} 123 124func TestInterleavedEmptyTextDoesNotSettleThinking (t * testing.T ) { 125m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 126m .SetSize (80 ,20 ) 127parts := []string {"First thinking token" ," second thinking token" ," third thinking token" } 128for _ ,part := range parts { 129m .handleStreamEvent (daemon.StreamEvent {Type :daemon .EventThinking ,Text :part }) 130m .handleStreamEvent (daemon.StreamEvent {Type :daemon .EventText ,Text :"" }) 131 } 132m .settleActiveStream () 133entries := m .History .Entries () 134if len (entries )!= 1 { 135t .Fatalf ("expected 1 settled thinking entry, got %d entries: %+v" ,len (entries ),entries ) 136 } 137if entries [0 ].Kind != EntryThinking { 138t .Fatalf ("expected EntryThinking, got %v" ,entries [0 ].Kind ) 139 } 140want := "First thinking token second thinking token third thinking token" 141if entries [0 ].Text != want { 142t .Fatalf ("expected %q, got %q" ,want ,entries [0 ].Text ) 143 } 144} 145 146func TestCompactTranscriptEntries (t * testing.T ) { 147r := NewTranscriptRenderer () 148flags := DisplayFlags {} 149thinking := HistoryEntry {Kind :EntryThinking ,Text :"Inspecting the repository\n" + strings .Repeat ("details " ,30 )} 150tool := HistoryEntry {Kind :EntryTool ,ToolName :"python" ,ToolArgs :map [string ]any {"code" :"print(1)" },ToolResult :`{"output": "` + strings .Repeat ("large " ,200 )+ `"}` } 151for _ ,width := range []int {20 ,80 } { 152for _ ,entry := range []HistoryEntry {thinking ,tool } { 153compact := r .RenderEntry (entry ,flags ,width ) 154if strings .Contains (compact ,"\n" )|| ansi .StringWidth (compact )> width { 155t .Fatalf ("not one bounded row at %d: %q" ,width ,compact ) 156 } 157 } 158 } 159if strings .Contains (r .RenderEntry (thinking ,flags ,80 ),"Inspecting" ) { 160t .Fatal ("collapsed thinking exposed body text" ) 161 } 162if ! strings .Contains (r .RenderEntry (thinking ,DisplayFlags {Thinking :true },80 ),"details" ) { 163t .Fatal ("thinking cannot be expanded" ) 164 } 165styled := r .RenderEntry (HistoryEntry {Kind :EntryThinking ,Text :"**Verifying scroll behavior**" },DisplayFlags {Thinking :true },80 ) 166if ! strings .Contains (styled ,"Verifying scroll behavior" )|| strings .Contains (styled ,"\x1b[1m" ) { 167t .Fatalf ("expanded thinking escaped faint styling: %q" ,styled ) 168 } 169if ! strings .Contains (r .RenderEntry (tool ,DisplayFlags {Tools :true },80 ),"large" ) { 170t .Fatal ("tool output cannot be expanded" ) 171 } 172tool .ToolResult = `{"status":"ok","output":"error: found in source\nTraceback (most recent call last): example","value":"","truncated":false}` 173if summary := r .RenderEntry (tool ,flags ,80 );strings .Contains (summary ,"failed" )|| strings .Contains (summary ,"\n" ) { 174t .Fatalf ("successful Python cell mislabelled as failure: %q" ,summary ) 175 } 176failed := tool 177failed .ToolResult = `{"status":"error","output":"trace","value":""}` 178collapsed := r .RenderEntry (failed ,flags ,80 ) 179if strings .Contains (collapsed ,"\n" )|| ! strings .Contains (collapsed ,"failed" ) { 180t .Fatalf ("failure should remain visible without flooding transcript: %q" ,collapsed ) 181 } 182} 183 184// A unit test is required here because daemon E2E cannot drive a TUI scroll position while 185// a committed stream re-renders the transcript. 186func TestReadingPositionSurvivesCommittedAppend (t * testing.T ) { 187m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 188m .SetSize (80 ,20 ) 189for i := range 30 { 190m .appendSettledEntry (HistoryEntry {ID :fmt .Sprintf ("old-%d" ,i ),Kind :EntryAssistant ,Text :fmt .Sprintf ("old row %d\n" ,i )}) 191 } 192m .Follow = false 193m .scrollOffset = 8 194m .refreshViewportContent () 195want := m .Viewport .View () 196m .appendSettledEntry (HistoryEntry {ID :"old-29" ,Kind :EntryAssistant ,Text :"old row 29\nnew row\n" }) 197m .refreshViewportContent () 198if got := m .Viewport .View ();got != want { 199t .Fatalf ("committed append moved reading position: offset=%d" ,m .scrollOffset ) 200 } 201} 202 203func TestReadingPositionSurvivesOutputPastTheLineCap (t * testing.T ) { 204m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 205m .SetSize (80 ,20 ) 206block := func (name string )HistoryEntry { 207return HistoryEntry {Kind :EntryAssistant ,Text :name + "\n" + strings .Repeat ("line\n" ,15 )} 208 } 209for i := range 60 { 210m .appendSettledEntry (block (fmt .Sprintf ("block %d" ,i ))) 211 } 212m .Follow = false 213m .scrollOffset = len (m .settledLines )/ 2 214m .refreshViewportContent () 215view ,offset := m .Viewport .View (),m .scrollOffset 216// Enough output to trim everything above the reading row at the normal cap. 217for i := range 40 { 218m .appendSettledEntry (block (fmt .Sprintf ("new %d" ,i ))) 219m .refreshViewportContent () 220if m .Viewport .View ()!= view || m .scrollOffset != offset { 221t .Fatalf ("after %d blocks the view moved: offset %d → %d" ,i + 1 ,offset ,m .scrollOffset ) 222 } 223 } 224m .SetSize (80 ,20 ) 225if m .Viewport .View ()!= view { 226t .Fatal ("re-rendering moved the reading position" ) 227 } 228m .scrollBy (1 << 20 ) 229m .appendSettledEntry (block ("tail" )) 230if ! m .Follow || len (m .settledLines )> MaxSettledLines { 231t .Fatalf ("caps not restored when following: follow=%v lines=%d" ,m .Follow ,len (m .settledLines )) 232 } 233} 234 235func TestAGrowingReplyRedrawsAtMostOncePerIntervalAndShowsItsLastTokens (t * testing.T ) { 236// Redraw pacing is a timing decision inside one Update; an e2e cannot 237// tell a throttled frame from a slow terminal. 238m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 239m .SetSize (80 ,20 ) 240delta := func (text string ) tea.Cmd { 241var cmd tea.Cmd 242m ,cmd = m .Update (ChatStreamEventMsg {SessionID :m .SessionID ,Generation :m .Generation , 243Event : daemon.StreamEvent {Type :daemon .EventText ,Text :text ,MessageID :"m" }}) 244return cmd 245 } 246delta ("first " ) 247m .liveInterval = time .Hour 248delta ("second " ) 249delta ("third" ) 250if strings .Contains (m .Viewport .View (),"third" )|| ! m .liveDue { 251t .Fatal ("a reply that only grew was redrawn inside its interval" ) 252 } 253m ,_ = m .Update (ChatLiveDrawMsg {SessionID :m .SessionID ,Generation :m .Generation }) 254if ! strings .Contains (ansi .Strip (m .Viewport .View ()),"first second third" ) { 255t .Fatalf ("the deferred redraw lost the last tokens: %q" ,ansi .Strip (m .Viewport .View ())) 256 } 257} 258 259func TestReturningToTheEndTrimsHistoryLoadedWhileReading (t * testing.T ) { 260// Older pages stretch both caps while you read; only the scroll back to 261// the end restores them, and no daemon event marks that moment. 262m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 263m .SetSize (80 ,20 ) 264m .History = NewBoundedHistory (10 ,1 << 20 ) 265for i := range 10 { 266m .appendSettledEntry (HistoryEntry {ID :fmt .Sprint ("new" ,i ),Seq :int64 (100 + i ),Kind :EntryAssistant ,Text :"newer" }) 267 } 268m .Follow ,m .scrollOffset ,m .loadingOlder = false ,0 ,true 269var older []daemon.StreamEvent 270for i := range 30 { 271older = append (older , daemon.StreamEvent {Type :daemon .EventMessage ,EntryID :fmt .Sprint ("old" ,i ),Position :int64 (i + 1 ),Text :"older" }) 272 } 273m = m .showOlder (ChatOlderLoadedMsg {SessionID :m .SessionID ,Generation :m .Generation ,Page :& daemon.HistoryPage {Events :older ,Before :1 }}) 274if m .History .Len ()<= 10 { 275t .Fatalf ("an older page was not kept while reading: %d entries" ,m .History .Len ()) 276 } 277for ! m .Follow { 278m .scrollBy (20 ) 279 } 280if m .History .Len ()!= 10 { 281t .Fatalf ("back at the end, history kept %d entries past its cap of 10" ,m .History .Len ()) 282 } 283}