// 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")
}
}