okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0a3eddd
main
1// Clickable rows and selections that scroll are drawn and 2// driven in the terminal client: the daemon e2e sees committed text, never 3// rendered rows or mouse events, so these regressions are invisible outside 4// the TUI. 5package tui 6 7import ( 8"fmt" 9"slices" 10"strings" 11"testing" 12 13"albedo/cli/internal/daemon" 14 tea"charm.land/bubbletea/v2" 15"github.com/charmbracelet/x/ansi" 16) 17 18// Dragging past the top edge scrolls the transcript and keeps selecting, so 19// the copy reaches rows that were never on screen together. 20func TestDraggingPastTheEdgeScrollsAndKeepsSelecting (t * testing.T ) { 21m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 22m .SetSize (80 ,20 ) 23for i := range 40 { 24m .appendSettledEntry (HistoryEntry {Kind :EntryNote ,Text :fmt .Sprintf ("note %02d" ,i ),Timestamp :int64 (i + 1 )}) 25 } 26m .refreshViewportContent () 27if ! m .Follow || m .scrollLimit == 0 { 28t .Fatalf ("fixture does not overflow: follow %v limit %d" ,m .Follow ,m .scrollLimit ) 29 } 30first := 2 + m .Notices .ChromeRows () 31at := func (row int ) tea.Mouse {return tea.Mouse {X :10 ,Y :first + row ,Button :tea .MouseLeft } } 32m ,_ = m .Update (tea .MouseClickMsg (at (m .Viewport .Height ()- 2 ))) 33m ,cmd := m .Update (tea .MouseMotionMsg (at (0 ))) 34if cmd == nil || m .dragDir != - 1 { 35t .Fatalf ("the top edge does not start a scroll: dir %d" ,m .dragDir ) 36 } 37start := m .scrollOffset 38for range 5 { 39m ,cmd = m .Update (dragScrollMsg {gen :m .dragGen }) 40 } 41if m .scrollOffset != start - 5 || m .dragHead .Row != m .scrollOffset || cmd == nil { 42t .Fatalf ("scrolled %d -> %d, head row %d" ,start ,m .scrollOffset ,m .dragHead .Row ) 43 } 44sel := Selection {Anchor :* m .dragAnchor ,Head :m .dragHead ,Gutter :railWidth } 45text := SelectedText (m .frameLines ,sel ) 46offscreen := strings .TrimSpace (ansi .Strip (m .frameLines [start - 2 ])) 47if ! strings .HasPrefix (offscreen ,"note" )|| ! strings .Contains (text ,offscreen ) { 48t .Fatalf ("the copy stops at the screen, missing %q:\n%s" ,offscreen ,text ) 49 } 50 51m ,_ = m .Update (tea .MouseMotionMsg (at (m .Viewport .Height ()/ 2 ))) 52if m .dragDir != 0 { 53t .Fatalf ("leaving the edge keeps scrolling: dir %d" ,m .dragDir ) 54 } 55} 56 57// The signoff copies the whole turn's prose as markdown, and the cells page lists every 58// call with the one under the cursor in full. 59func rowWith (t * testing.T ,lines []string ,text string )int { 60t .Helper () 61for i ,line := range lines { 62if strings .Contains (ansi .Strip (line ),text ) { 63return i 64 } 65 } 66t .Fatalf ("no row says %q" ,text ) 67return - 1 68} 69 70func TestSignoffCopiesTheTurnAsMarkdown (t * testing.T ) { 71m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 72m .SetSize (80 ,40 ) 73m .appendSettledEntry (HistoryEntry {Kind :EntryUser ,Text :"hi" ,Timestamp :1 }) 74m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :"# first\n\n```go\nx := 1\n```" ,Timestamp :2 }) 75m .appendSettledEntry (HistoryEntry {Kind :EntryTool ,ToolName :"bash" ,Timestamp :3 }) 76m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :"**second**" ,Timestamp :4 }) 77m .appendSettledEntry (HistoryEntry {Kind :EntryTurnEnd ,Mood :moodDone ,Timestamp :5 }) 78m .refreshViewportContent () 79row := rowWith (t ,m .frameLines ,"⧉" ) 80act ,_ := actionOf (m .frameLines [row ]) 81if cmd := m .actAt (row );cmd == nil || m .CopyStatus != "copied" { 82t .Fatalf ("the signoff did not copy: status %q" ,m .CopyStatus ) 83 } 84if got ,_ := m .replyMarkdown (act .key );got != "# first\n\n```go\nx := 1\n```\n\n**second**" { 85t .Fatalf ("the signoff copied %q" ,got ) 86 } 87} 88 89// The rate sits right before the copy mark, after the turn's time and tools. 90func TestSignoffShowsItsRateBeforeTheCopyMark (t * testing.T ) { 91m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 92m .SetSize (80 ,40 ) 93m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :"done" ,Timestamp :1 }) 94m .appendSettledEntry (HistoryEntry {Kind :EntryTurnEnd ,Mood :moodDone ,ElapsedMs :5000 ,Tools :1 ,TokensPerSecond :84.2 ,Timestamp :2 }) 95m .refreshViewportContent () 96row := ansi .Strip (m .frameLines [rowWith (t ,m .frameLines ,"⧉" )]) 97if ! strings .Contains (row ,"1 tool · 84.2 tok/s · ⧉" ) { 98t .Fatalf ("signoff %q does not put the rate before the copy mark" ,row ) 99 } 100} 101 102// Clicking a burst's summary lists every step in full, the commands the 103// summary only names included, and clicking it again folds them away. 104func TestClickingABurstListsItsSteps (t * testing.T ) { 105m := newTestChatModel (t ,& daemon.Session {ID :"s" ,Workspace :"/w/albedo" }) 106m .SetSize (120 ,40 ) 107m .appendSettledEntry (HistoryEntry {Kind :EntryUser ,Text :"go" ,Timestamp :1 }) 108for _ ,entry := range burstFixture () { 109m .appendSettledEntry (entry ) 110 } 111m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :"done" ,Timestamp :9 }) 112m .refreshViewportContent () 113full := "cd cli && go test ./..." 114row := rowWith (t ,m .frameLines ,"▸ thought" ) 115if text := ansi .Strip (strings .Join (m .frameLines ,"\n" ));strings .Contains (text ,full ) { 116t .Fatalf ("a closed burst lists its steps:\n%s" ,text ) 117 } 118m .actAt (row ) 119spliced := slices .Clone (m .settledLines ) 120if m .rebuildSettledLines ();! slices .Equal (spliced ,m .settledLines ) { 121t .Fatal ("opening the burst drew rows a rebuild does not" ) 122 } 123m .refreshViewportContent () 124text := ansi .Strip (strings .Join (m .frameLines ,"\n" )) 125for _ ,step := range []string {"▾ thought" ,"run " + full ,"read cli/internal/tui/chat.go" ,"edit priv/python/albedo_trace.py" ,"python · 1/0" } { 126if ! strings .Contains (text ,step ) { 127t .Fatalf ("the open burst does not show %q:\n%s" ,step ,text ) 128 } 129 } 130m .actAt (rowWith (t ,m .frameLines ,"▾ thought" )) 131if text := ansi .Strip (strings .Join (m .frameLines ,"\n" ));strings .Contains (text ,full )|| ! strings .Contains (text ,"▸ thought" ) { 132t .Fatalf ("a second click does not close the burst:\n%s" ,text ) 133 } 134} 135 136// A long message of yours shows its first rows and a click target; clicking 137// it shows the rest, and the row that closes it folds the message again. 138func TestClickingALongMessageShowsTheRestAndFoldsIt (t * testing.T ) { 139m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 140m .SetSize (100 ,30 ) 141var lines []string 142for i := range 40 { 143lines = append (lines ,fmt .Sprintf ("line %02d" ,i )) 144 } 145m .appendSettledEntry (HistoryEntry {Kind :EntryUser ,Text :strings .Join (lines ,"\n\n" ),Timestamp :1 }) 146m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :"done" ,Timestamp :2 }) 147m .refreshViewportContent () 148shown := func ()string {return ansi .Strip (strings .Join (m .frameLines ,"\n" )) } 149if text := shown ();strings .Contains (text ,"line 39" )|| ! strings .Contains (text ,"more lines · click to expand" ) { 150t .Fatalf ("a long message is not folded:\n%s" ,text ) 151 } 152spliced := func () { 153t .Helper () 154got := slices .Clone (m .settledLines ) 155if m .rebuildSettledLines ();! slices .Equal (got ,m .settledLines ) { 156t .Fatal ("the splice drew rows a rebuild does not" ) 157 } 158m .refreshViewportContent () 159 } 160click := rowWith (t ,m .frameLines ,"click to expand" ) 161m .actAt (click ) 162spliced () 163if text := shown ();! strings .Contains (text ,"line 39" )|| ! strings .Contains (text ,"show less" ) { 164t .Fatalf ("clicking does not show the rest:\n%s" ,text ) 165 } 166m .actAt (rowWith (t ,m .frameLines ,"show less" )) 167spliced () 168if text := shown ();strings .Contains (text ,"line 39" )|| ! strings .Contains (text ,"click to expand" ) { 169t .Fatalf ("clicking again does not fold the message:\n%s" ,text ) 170 } 171} 172 173// A web search's answer shows under its burst, cut to its first rows; the 174// click target shows the rest and folds it again, splicing what a rebuild 175// would draw. 176func TestClickingAWebSearchShowsItsWholeAnswer (t * testing.T ) { 177m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 178m .SetSize (100 ,30 ) 179var lines []string 180for i := range 12 { 181lines = append (lines ,fmt .Sprintf ("finding %02d" ,i )) 182 } 183search := daemon.ToolActivity {Kind :"web" ,Target :"latest gleam release" ,Detail :strings .Join (lines ,"\n\n" )} 184m .appendSettledEntry (HistoryEntry {Kind :EntryUser ,Text :"look it up" ,Timestamp :1 }) 185m .appendSettledEntry (traced ([]daemon.ToolActivity {search })) 186m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :"done" ,Timestamp :3 }) 187m .refreshViewportContent () 188shown := func ()string {return ansi .Strip (strings .Join (m .frameLines ,"\n" )) } 189if text := shown ();! strings .Contains (text ,"looked up “latest gleam release”" )|| ! strings .Contains (text ,"finding 00" )|| strings .Contains (text ,"finding 11" ) { 190t .Fatalf ("the search is not summarized and folded:\n%s" ,text ) 191 } 192spliced := func () { 193t .Helper () 194got := slices .Clone (m .settledLines ) 195if m .rebuildSettledLines ();! slices .Equal (got ,m .settledLines ) { 196t .Fatal ("the splice drew rows a rebuild does not" ) 197 } 198m .refreshViewportContent () 199 } 200m .actAt (rowWith (t ,m .frameLines ,"click to expand" )) 201spliced () 202if text := shown ();! strings .Contains (text ,"finding 11" )|| ! strings .Contains (text ,"show less" ) { 203t .Fatalf ("clicking does not show the whole answer:\n%s" ,text ) 204 } 205m .actAt (rowWith (t ,m .frameLines ,"show less" )) 206spliced () 207if text := shown ();strings .Contains (text ,"finding 11" )|| ! strings .Contains (text ,"click to expand" ) { 208t .Fatalf ("clicking again does not fold the answer:\n%s" ,text ) 209 } 210} 211 212// Capability notes must not pad short rows to thousands of columns. 213func TestALongNoteWrapsRowByRowAndFolds (t * testing.T ) { 214m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 215m .SetSize (100 ,30 ) 216lines := []string {"Session capabilities changed." ,strings .Repeat ("instructions " ,200 )} 217for i := range 20 { 218lines = append (lines ,fmt .Sprintf ("- **`dir%02d/`** — a short bullet" ,i )) 219 } 220m .appendSettledEntry (HistoryEntry {Kind :EntryNote ,Source :"capabilities changed" ,Text :strings .Join (lines ,"\n" ),Timestamp :1 }) 221m .appendSettledEntry (HistoryEntry {Kind :EntryAssistant ,Text :"done" ,Timestamp :2 }) 222m .refreshViewportContent () 223bounded := func () { 224t .Helper () 225for _ ,row := range m .settledLines { 226if ansi .StringWidth (row )> 100 { 227t .Fatalf ("a note row is wider than the screen: %d columns" ,ansi .StringWidth (row )) 228 } 229if plain := ansi .Strip (row );strings .TrimSpace (plain )!= "" && strings .HasSuffix (plain ," " ) { 230t .Fatalf ("a note row is padded to a wider row: %q" ,plain ) 231 } 232 } 233 } 234spliced := func () { 235t .Helper () 236got := slices .Clone (m .settledLines ) 237m .rebuildSettledLines () 238if ! slices .Equal (got ,m .settledLines ) { 239t .Fatal ("expanding or folding the note disagrees with a rebuild" ) 240 } 241m .refreshViewportContent () 242bounded () 243 } 244shown := func ()string {return ansi .Strip (strings .Join (m .frameLines ,"\n" )) } 245bounded () 246if text := shown ();strings .Contains (text ,"dir19/" )|| ! strings .Contains (text ,"more lines · click to expand" ) { 247t .Fatalf ("a long note is not folded:\n%s" ,text ) 248 } 249m .actAt (rowWith (t ,m .frameLines ,"click to expand" )) 250spliced () 251if text := shown ();! strings .Contains (text ,"**`dir19/`**" )|| ! strings .Contains (text ,"show less" ) { 252t .Fatalf ("clicking does not show the rest of the plain-text note:\n%s" ,text ) 253 } 254m .actAt (rowWith (t ,m .frameLines ,"show less" )) 255spliced () 256if text := shown ();strings .Contains (text ,"dir19/" )|| ! strings .Contains (text ,"click to expand" ) { 257t .Fatalf ("clicking again does not fold the note:\n%s" ,text ) 258 } 259}