okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0b9f48f
main
1// The composer loses user input and breaks its frame in ways only keystrokes 2// and a real wrapping layout reveal: the daemon never sees a keystroke, and 3// the e2e harness drives the TUI in process with no PTY, so wrap boundaries, 4// scroll behavior, terminal handoff around the editor, and grapheme widths 5// cannot be observed there. Async status ordering is controlled here because 6// real HTTP cannot deterministically deliver a superseded reply after a reset. 7package tui 8 9import ( 10"fmt" 11"os/exec" 12"strings" 13"testing" 14 15"albedo/cli/internal/daemon" 16 tea"charm.land/bubbletea/v2" 17"github.com/charmbracelet/x/ansi" 18) 19 20// composer sizes a chat like a small terminal. 21func composer (t * testing.T ,width ,height int )ChatModel { 22t .Helper () 23m := newTestChatModel (t ,& daemon.Session {ID :"s" }) 24m .SetSize (width ,height ) 25return m 26} 27 28func typePrompt (m ChatModel ,text string )ChatModel { 29for _ ,r := range text { 30m ,_ = m .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 31 } 32return m 33} 34 35// frame checks the chat view is exactly height rows with the separator 36// directly above the footer, and hands back the stripped rows. 37func frame (t * testing.T ,m ChatModel ,height int ) []string { 38t .Helper () 39lines := strings .Split (ansi .Strip (m .View ()),"\n" ) 40if len (lines )!= height { 41t .Fatalf ("view is %d rows, want %d" ,len (lines ),height ) 42 } 43if ! strings .Contains (lines [len (lines )- 2 ],"─" ) { 44t .Fatalf ("expected the separator above the footer, got %q" ,lines [len (lines )- 2 ]) 45 } 46if ! strings .Contains (lines [len (lines )- 1 ],"/ commands" ) { 47t .Fatalf ("expected the footer last, got %q" ,lines [len (lines )- 1 ]) 48 } 49return lines 50} 51 52// One interaction scenario: a long prompt wraps without losing text or 53// leaving a blank row, cursor keys move inside the wrap without scrolling the 54// transcript, submit resets the layout, alt+enter adds a line, and a prompt 55// past six visual rows scrolls inside the cap without a character limit. 56func TestPromptMultilineWrapsScrollsAndSubmits (t * testing.T ) { 57const width ,height = 60 ,25 58altEnter := tea.KeyPressMsg {Code :tea .KeyEnter ,Mod :tea .ModAlt } 59m := composer (t ,width ,height ) 60 61// enter while connecting keeps the draft instead of sending it. 62m = typePrompt (m ,"too early" ) 63m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 64if m .TextArea .Value ()!= "too early" || len (m .pendingUsers )!= 0 { 65t .Fatalf ("enter while connecting sent %q (pending %d), want the draft kept" ,m .TextArea .Value (),len (m .pendingUsers )) 66 } 67m .TextArea .Reset () 68m .Status .Phase = & phaseResting 69 70if m .promptHeight ()!= 1 { 71t .Fatalf ("initial promptHeight %d, want 1" ,m .promptHeight ()) 72 } 73resting := height - 6 - m .chromeRows () 74if m .Viewport .Height ()!= resting { 75t .Fatalf ("viewport height %d, want %d" ,m .Viewport .Height (),resting ) 76 } 77frame (t ,m ,height ) 78 79long := "this is a very long prompt sentence that should definitely wrap across multiple lines in a sixty column terminal" 80m = typePrompt (m ,long ) 81if m .promptHeight ()<= 1 { 82t .Fatalf ("promptHeight %d, want the long line wrapped" ,m .promptHeight ()) 83 } 84lines := frame (t ,m ,height ) 85if view := ansi .Strip (m .View ());! strings .Contains (view ,"this is a very long prompt" ) { 86t .Fatalf ("the prompt's first row vanished from the view:\n%s" ,view ) 87 } 88if m .TextArea .Value ()!= long { 89t .Fatalf ("typing lost input: %q" ,m .TextArea .Value ()) 90 } 91if last := strings .TrimSpace (lines [len (lines )- 3 ]);last == "" { 92t .Fatalf ("the prompt has an empty row above the separator: %q" ,lines [len (lines )- 3 ]) 93 } 94 95// Cursor keys move inside the wrapped prompt and never scroll the 96// transcript. 97m .TextArea .CursorStart () 98if m .TextArea .Line ()!= 0 || m .TextArea .LineInfo ().RowOffset != 0 { 99t .Fatalf ("cursor not at the start: line=%d rowOffset=%d" ,m .TextArea .Line (),m .TextArea .LineInfo ().RowOffset ) 100 } 101m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 102if m .TextArea .LineInfo ().RowOffset != 1 { 103t .Fatalf ("rowOffset %d after KeyDown, want 1" ,m .TextArea .LineInfo ().RowOffset ) 104 } 105beforeScroll := m .scrollOffset 106m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyUp }) 107if m .TextArea .LineInfo ().RowOffset != 0 { 108t .Fatalf ("rowOffset %d after KeyUp, want 0" ,m .TextArea .LineInfo ().RowOffset ) 109 } 110if m .scrollOffset != beforeScroll { 111t .Fatalf ("KeyUp in the middle of the prompt scrolled the transcript: before=%d after=%d" ,beforeScroll ,m .scrollOffset ) 112 } 113 114// Submit resets the prompt to one line and gives the viewport its height 115// back. 116m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 117if m .promptHeight ()!= 1 { 118t .Fatalf ("promptHeight %d after Enter, want 1" ,m .promptHeight ()) 119 } 120if m .Viewport .Height ()!= resting { 121t .Fatalf ("viewport height %d after Enter, want %d" ,m .Viewport .Height (),resting ) 122 } 123 124// Alt+enter adds a line instead of submitting. 125m = typePrompt (m ,"hello" ) 126m ,_ = m .Update (altEnter ) 127m = typePrompt (m ,"world" ) 128if m .promptHeight ()!= 2 { 129t .Fatalf ("promptHeight %d after alt+enter, want 2" ,m .promptHeight ()) 130 } 131frame (t ,m ,height ) 132 133// Ten alt+enter lines exceed every limit: the value keeps every 134// character, the prompt caps at six visual rows, and the latest line 135// stays visible in the scrolled view. 136m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 137for i := 1 ;i <= 10 ;i ++ { 138m = typePrompt (m ,fmt .Sprintf ("line %d with plenty of characters to exceed 400 total chars across all lines" ,i )) 139_ = m .View () 140if i < 10 { 141m ,_ = m .Update (altEnter ) 142_ = m .View () 143 } 144 } 145if len (m .TextArea .Value ())<= 400 { 146t .Fatalf ("prompt kept only %d chars, want every line" ,len (m .TextArea .Value ())) 147 } 148if m .promptHeight ()!= 6 { 149t .Fatalf ("promptHeight %d, want the six-row cap" ,m .promptHeight ()) 150 } 151frame (t ,m ,height ) 152if view := ansi .Strip (m .View ());! strings .Contains (view ,"line 10" ) { 153t .Fatalf ("line 10 is not visible in the scrolled view:\n%s" ,view ) 154 } 155} 156 157// The repaint after the editor starts wherever the cursor is, so the editor 158// process must hand the terminal back at the start of a row, with grapheme 159// widths back on when the terminal had them. Only a real child process and 160// its escape sequences exercise this. 161func TestEditorProcessRestoresTerminal (t * testing.T ) { 162for _ ,tc := range []struct { 163want string 164graphemes bool 165 }{ 166 {graphemes :false ,want :"echoed reply\r" }, 167 {graphemes :true ,want :"echoed reply\r" + ansi .SetModeUnicodeCore }, 168 } { 169var out strings.Builder 170p := editorProcess {exec .Command ("sh" ,"-c" ,"printf 'echoed reply'" ),tc .graphemes } 171p .SetStdout (& out ) 172if err := p .Run ();err != nil { 173t .Fatal (err ) 174 } 175if out .String ()!= tc .want { 176t .Fatalf ("graphemes %v: expected %q, got %q" ,tc .graphemes ,tc .want ,out .String ()) 177 } 178 } 179} 180 181// A terminal without mode 2027 must not measure graphemes, and one that 182// reports the mode keeps the setting across chats. The report only exists in 183// a real terminal, so no e2e surface carries it. 184func TestAppRemembersGraphemeTerminal (t * testing.T ) { 185m := & AppModel {Conn :daemon .NewConnection (daemon.ConnectionSnapshot {Port :1 },nil )} 186next ,_ := m .Update (tea.ModeReportMsg {Mode :ansi .ModeUnicodeCore ,Value :ansi .ModeNotRecognized }) 187if next .(* AppModel ).Graphemes { 188t .Fatal ("a terminal without mode 2027 does not measure graphemes" ) 189 } 190next ,_ = next .Update (tea.ModeReportMsg {Mode :ansi .ModeUnicodeCore ,Value :ansi .ModeReset }) 191app := next .(* AppModel ) 192if ! app .Graphemes || ! app .Chat .graphemes { 193t .Fatal ("expected the app and its chat to remember grapheme widths" ) 194 } 195if chat := app .newChatModel (& daemon.Session {ID :"s" });! chat .graphemes { 196t .Fatal ("expected a new chat to inherit grapheme widths" ) 197 } 198} 199 200// The order of a reset and status replies is controlled here: real HTTP cannot 201// deterministically deliver a superseded status after a snapshot arrives. 202func TestComposerIgnoresStaleStatusAndAcceptsIdleWithoutPhase (t * testing.T ) { 203const draft = "keep this draft" 204m := composer (t ,80 ,22 ) 205defer m .Close () 206status := ChatStatusMsg { 207SessionID :m .SessionID ,Generation :m .Generation , 208Revision :m .statusRevision ,Status :& daemon.AgentStatus {Idle :true }, 209 } 210m ,_ = m .Update (ChatStreamEventMsg { 211SessionID :m .SessionID ,Generation :m .Generation , 212Event : daemon.StreamEvent {Type :daemon .EventReset }, 213 }) 214m ,_ = m .Update (status ) 215m = typePrompt (m ,draft ) 216m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 217if m .TextArea .Value ()!= draft || len (m .pendingUsers )!= 0 { 218t .Fatal ("a stale status unblocked the composer" ) 219 } 220status .Revision = m .statusRevision 221m ,_ = m .Update (status ) 222m .TextArea .Reset () 223if ! strings .Contains (ansi .Strip (m .View ()),"ctrl+g editor" ) { 224t .Fatal ("an idle status without a phase did not restore the input cue" ) 225 } 226m .TextArea .SetValue (draft ) 227m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 228pending := m .pendingUsers 229if m .TextArea .Value ()!= "" || len (pending )!= 1 || pending [0 ].Text != draft { 230t .Fatal ("an idle status without a phase refused the draft" ) 231 } 232}