// The composer loses user input and breaks its frame in ways only keystrokes // and a real wrapping layout reveal: the daemon never sees a keystroke, and // the e2e harness drives the TUI in process with no PTY, so wrap boundaries, // scroll behavior, terminal handoff around the editor, and grapheme widths // cannot be observed there. Async status ordering is controlled here because // real HTTP cannot deterministically deliver a superseded reply after a reset. package tui import ( "fmt" "os/exec" "strings" "testing" "albedo/cli/internal/daemon" tea "charm.land/bubbletea/v2" "github.com/charmbracelet/x/ansi" ) // composer sizes a chat like a small terminal. func composer(t *testing.T, width, height int) ChatModel { t.Helper() m := newTestChatModel(t, &daemon.Session{ID: "s"}) m.SetSize(width, height) return m } func typePrompt(m ChatModel, text string) ChatModel { for _, r := range text { m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)}) } return m } // frame checks the chat view is exactly height rows with the separator // directly above the footer, and hands back the stripped rows. func frame(t *testing.T, m ChatModel, height int) []string { t.Helper() lines := strings.Split(ansi.Strip(m.View()), "\n") if len(lines) != height { t.Fatalf("view is %d rows, want %d", len(lines), height) } if !strings.Contains(lines[len(lines)-2], "─") { t.Fatalf("expected the separator above the footer, got %q", lines[len(lines)-2]) } if !strings.Contains(lines[len(lines)-1], "/ commands") { t.Fatalf("expected the footer last, got %q", lines[len(lines)-1]) } return lines } // One interaction scenario: a long prompt wraps without losing text or // leaving a blank row, cursor keys move inside the wrap without scrolling the // transcript, submit resets the layout, alt+enter adds a line, and a prompt // past six visual rows scrolls inside the cap without a character limit. func TestPromptMultilineWrapsScrollsAndSubmits(t *testing.T) { const width, height = 60, 25 altEnter := tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt} m := composer(t, width, height) // enter while connecting keeps the draft instead of sending it. m = typePrompt(m, "too early") m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if m.TextArea.Value() != "too early" || len(m.pendingUsers) != 0 { t.Fatalf("enter while connecting sent %q (pending %d), want the draft kept", m.TextArea.Value(), len(m.pendingUsers)) } m.TextArea.Reset() m.Status.Phase = &phaseResting if m.promptHeight() != 1 { t.Fatalf("initial promptHeight %d, want 1", m.promptHeight()) } resting := height - 6 - m.chromeRows() if m.Viewport.Height() != resting { t.Fatalf("viewport height %d, want %d", m.Viewport.Height(), resting) } frame(t, m, height) long := "this is a very long prompt sentence that should definitely wrap across multiple lines in a sixty column terminal" m = typePrompt(m, long) if m.promptHeight() <= 1 { t.Fatalf("promptHeight %d, want the long line wrapped", m.promptHeight()) } lines := frame(t, m, height) if view := ansi.Strip(m.View()); !strings.Contains(view, "this is a very long prompt") { t.Fatalf("the prompt's first row vanished from the view:\n%s", view) } if m.TextArea.Value() != long { t.Fatalf("typing lost input: %q", m.TextArea.Value()) } if last := strings.TrimSpace(lines[len(lines)-3]); last == "" { t.Fatalf("the prompt has an empty row above the separator: %q", lines[len(lines)-3]) } // Cursor keys move inside the wrapped prompt and never scroll the // transcript. m.TextArea.CursorStart() if m.TextArea.Line() != 0 || m.TextArea.LineInfo().RowOffset != 0 { t.Fatalf("cursor not at the start: line=%d rowOffset=%d", m.TextArea.Line(), m.TextArea.LineInfo().RowOffset) } m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown}) if m.TextArea.LineInfo().RowOffset != 1 { t.Fatalf("rowOffset %d after KeyDown, want 1", m.TextArea.LineInfo().RowOffset) } beforeScroll := m.scrollOffset m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp}) if m.TextArea.LineInfo().RowOffset != 0 { t.Fatalf("rowOffset %d after KeyUp, want 0", m.TextArea.LineInfo().RowOffset) } if m.scrollOffset != beforeScroll { t.Fatalf("KeyUp in the middle of the prompt scrolled the transcript: before=%d after=%d", beforeScroll, m.scrollOffset) } // Submit resets the prompt to one line and gives the viewport its height // back. m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if m.promptHeight() != 1 { t.Fatalf("promptHeight %d after Enter, want 1", m.promptHeight()) } if m.Viewport.Height() != resting { t.Fatalf("viewport height %d after Enter, want %d", m.Viewport.Height(), resting) } // Alt+enter adds a line instead of submitting. m = typePrompt(m, "hello") m, _ = m.Update(altEnter) m = typePrompt(m, "world") if m.promptHeight() != 2 { t.Fatalf("promptHeight %d after alt+enter, want 2", m.promptHeight()) } frame(t, m, height) // Ten alt+enter lines exceed every limit: the value keeps every // character, the prompt caps at six visual rows, and the latest line // stays visible in the scrolled view. m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) for i := 1; i <= 10; i++ { m = typePrompt(m, fmt.Sprintf("line %d with plenty of characters to exceed 400 total chars across all lines", i)) _ = m.View() if i < 10 { m, _ = m.Update(altEnter) _ = m.View() } } if len(m.TextArea.Value()) <= 400 { t.Fatalf("prompt kept only %d chars, want every line", len(m.TextArea.Value())) } if m.promptHeight() != 6 { t.Fatalf("promptHeight %d, want the six-row cap", m.promptHeight()) } frame(t, m, height) if view := ansi.Strip(m.View()); !strings.Contains(view, "line 10") { t.Fatalf("line 10 is not visible in the scrolled view:\n%s", view) } } // The repaint after the editor starts wherever the cursor is, so the editor // process must hand the terminal back at the start of a row, with grapheme // widths back on when the terminal had them. Only a real child process and // its escape sequences exercise this. func TestEditorProcessRestoresTerminal(t *testing.T) { for _, tc := range []struct { want string graphemes bool }{ {graphemes: false, want: "echoed reply\r"}, {graphemes: true, want: "echoed reply\r" + ansi.SetModeUnicodeCore}, } { var out strings.Builder p := editorProcess{exec.Command("sh", "-c", "printf 'echoed reply'"), tc.graphemes} p.SetStdout(&out) if err := p.Run(); err != nil { t.Fatal(err) } if out.String() != tc.want { t.Fatalf("graphemes %v: expected %q, got %q", tc.graphemes, tc.want, out.String()) } } } // A terminal without mode 2027 must not measure graphemes, and one that // reports the mode keeps the setting across chats. The report only exists in // a real terminal, so no e2e surface carries it. func TestAppRemembersGraphemeTerminal(t *testing.T) { m := &AppModel{Conn: daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)} next, _ := m.Update(tea.ModeReportMsg{Mode: ansi.ModeUnicodeCore, Value: ansi.ModeNotRecognized}) if next.(*AppModel).Graphemes { t.Fatal("a terminal without mode 2027 does not measure graphemes") } next, _ = next.Update(tea.ModeReportMsg{Mode: ansi.ModeUnicodeCore, Value: ansi.ModeReset}) app := next.(*AppModel) if !app.Graphemes || !app.Chat.graphemes { t.Fatal("expected the app and its chat to remember grapheme widths") } if chat := app.newChatModel(&daemon.Session{ID: "s"}); !chat.graphemes { t.Fatal("expected a new chat to inherit grapheme widths") } } // The order of a reset and status replies is controlled here: real HTTP cannot // deterministically deliver a superseded status after a snapshot arrives. func TestComposerIgnoresStaleStatusAndAcceptsIdleWithoutPhase(t *testing.T) { const draft = "keep this draft" m := composer(t, 80, 22) defer m.Close() status := ChatStatusMsg{ SessionID: m.SessionID, Generation: m.Generation, Revision: m.statusRevision, Status: &daemon.AgentStatus{Idle: true}, } m, _ = m.Update(ChatStreamEventMsg{ SessionID: m.SessionID, Generation: m.Generation, Event: daemon.StreamEvent{Type: daemon.EventReset}, }) m, _ = m.Update(status) m = typePrompt(m, draft) m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) if m.TextArea.Value() != draft || len(m.pendingUsers) != 0 { t.Fatal("a stale status unblocked the composer") } status.Revision = m.statusRevision m, _ = m.Update(status) m.TextArea.Reset() if !strings.Contains(ansi.Strip(m.View()), "ctrl+g editor") { t.Fatal("an idle status without a phase did not restore the input cue") } m.TextArea.SetValue(draft) m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter}) pending := m.pendingUsers if m.TextArea.Value() != "" || len(pending) != 1 || pending[0].Text != draft { t.Fatal("an idle status without a phase refused the draft") } }