okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

nekomimi.pettui: pointer app model, shared styles, reused glamour renderers, own transcript pane, reuse rows across older pages, gogc 500b9f48f

main
8.6 KiB232 linesraw
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 {
22	t.Helper()
23	m := newTestChatModel(t, &daemon.Session{ID: "s"})
24	m.SetSize(width, height)
25	return m
26}
27
28func typePrompt(m ChatModel, text string) ChatModel {
29	for _, r := range text {
30		m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
31	}
32	return 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 {
38	t.Helper()
39	lines := strings.Split(ansi.Strip(m.View()), "\n")
40	if len(lines) != height {
41		t.Fatalf("view is %d rows, want %d", len(lines), height)
42	}
43	if !strings.Contains(lines[len(lines)-2], "─") {
44		t.Fatalf("expected the separator above the footer, got %q", lines[len(lines)-2])
45	}
46	if !strings.Contains(lines[len(lines)-1], "/ commands") {
47		t.Fatalf("expected the footer last, got %q", lines[len(lines)-1])
48	}
49	return 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) {
57	const width, height = 60, 25
58	altEnter := tea.KeyPressMsg{Code: tea.KeyEnter, Mod: tea.ModAlt}
59	m := composer(t, width, height)
60
61	// enter while connecting keeps the draft instead of sending it.
62	m = typePrompt(m, "too early")
63	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
64	if m.TextArea.Value() != "too early" || len(m.pendingUsers) != 0 {
65		t.Fatalf("enter while connecting sent %q (pending %d), want the draft kept", m.TextArea.Value(), len(m.pendingUsers))
66	}
67	m.TextArea.Reset()
68	m.Status.Phase = &phaseResting
69
70	if m.promptHeight() != 1 {
71		t.Fatalf("initial promptHeight %d, want 1", m.promptHeight())
72	}
73	resting := height - 6 - m.chromeRows()
74	if m.Viewport.Height() != resting {
75		t.Fatalf("viewport height %d, want %d", m.Viewport.Height(), resting)
76	}
77	frame(t, m, height)
78
79	long := "this is a very long prompt sentence that should definitely wrap across multiple lines in a sixty column terminal"
80	m = typePrompt(m, long)
81	if m.promptHeight() <= 1 {
82		t.Fatalf("promptHeight %d, want the long line wrapped", m.promptHeight())
83	}
84	lines := frame(t, m, height)
85	if view := ansi.Strip(m.View()); !strings.Contains(view, "this is a very long prompt") {
86		t.Fatalf("the prompt's first row vanished from the view:\n%s", view)
87	}
88	if m.TextArea.Value() != long {
89		t.Fatalf("typing lost input: %q", m.TextArea.Value())
90	}
91	if last := strings.TrimSpace(lines[len(lines)-3]); last == "" {
92		t.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.
97	m.TextArea.CursorStart()
98	if m.TextArea.Line() != 0 || m.TextArea.LineInfo().RowOffset != 0 {
99		t.Fatalf("cursor not at the start: line=%d rowOffset=%d", m.TextArea.Line(), m.TextArea.LineInfo().RowOffset)
100	}
101	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
102	if m.TextArea.LineInfo().RowOffset != 1 {
103		t.Fatalf("rowOffset %d after KeyDown, want 1", m.TextArea.LineInfo().RowOffset)
104	}
105	beforeScroll := m.scrollOffset
106	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyUp})
107	if m.TextArea.LineInfo().RowOffset != 0 {
108		t.Fatalf("rowOffset %d after KeyUp, want 0", m.TextArea.LineInfo().RowOffset)
109	}
110	if m.scrollOffset != beforeScroll {
111		t.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.
116	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
117	if m.promptHeight() != 1 {
118		t.Fatalf("promptHeight %d after Enter, want 1", m.promptHeight())
119	}
120	if m.Viewport.Height() != resting {
121		t.Fatalf("viewport height %d after Enter, want %d", m.Viewport.Height(), resting)
122	}
123
124	// Alt+enter adds a line instead of submitting.
125	m = typePrompt(m, "hello")
126	m, _ = m.Update(altEnter)
127	m = typePrompt(m, "world")
128	if m.promptHeight() != 2 {
129		t.Fatalf("promptHeight %d after alt+enter, want 2", m.promptHeight())
130	}
131	frame(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.
136	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
137	for i := 1; i <= 10; i++ {
138		m = typePrompt(m, fmt.Sprintf("line %d with plenty of characters to exceed 400 total chars across all lines", i))
139		_ = m.View()
140		if i < 10 {
141			m, _ = m.Update(altEnter)
142			_ = m.View()
143		}
144	}
145	if len(m.TextArea.Value()) <= 400 {
146		t.Fatalf("prompt kept only %d chars, want every line", len(m.TextArea.Value()))
147	}
148	if m.promptHeight() != 6 {
149		t.Fatalf("promptHeight %d, want the six-row cap", m.promptHeight())
150	}
151	frame(t, m, height)
152	if view := ansi.Strip(m.View()); !strings.Contains(view, "line 10") {
153		t.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) {
162	for _, tc := range []struct {
163		want      string
164		graphemes bool
165	}{
166		{graphemes: false, want: "echoed reply\r"},
167		{graphemes: true, want: "echoed reply\r" + ansi.SetModeUnicodeCore},
168	} {
169		var out strings.Builder
170		p := editorProcess{exec.Command("sh", "-c", "printf 'echoed reply'"), tc.graphemes}
171		p.SetStdout(&out)
172		if err := p.Run(); err != nil {
173			t.Fatal(err)
174		}
175		if out.String() != tc.want {
176			t.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) {
185	m := &AppModel{Conn: daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)}
186	next, _ := m.Update(tea.ModeReportMsg{Mode: ansi.ModeUnicodeCore, Value: ansi.ModeNotRecognized})
187	if next.(*AppModel).Graphemes {
188		t.Fatal("a terminal without mode 2027 does not measure graphemes")
189	}
190	next, _ = next.Update(tea.ModeReportMsg{Mode: ansi.ModeUnicodeCore, Value: ansi.ModeReset})
191	app := next.(*AppModel)
192	if !app.Graphemes || !app.Chat.graphemes {
193		t.Fatal("expected the app and its chat to remember grapheme widths")
194	}
195	if chat := app.newChatModel(&daemon.Session{ID: "s"}); !chat.graphemes {
196		t.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) {
203	const draft = "keep this draft"
204	m := composer(t, 80, 22)
205	defer m.Close()
206	status := ChatStatusMsg{
207		SessionID: m.SessionID, Generation: m.Generation,
208		Revision: m.statusRevision, Status: &daemon.AgentStatus{Idle: true},
209	}
210	m, _ = m.Update(ChatStreamEventMsg{
211		SessionID: m.SessionID, Generation: m.Generation,
212		Event: daemon.StreamEvent{Type: daemon.EventReset},
213	})
214	m, _ = m.Update(status)
215	m = typePrompt(m, draft)
216	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
217	if m.TextArea.Value() != draft || len(m.pendingUsers) != 0 {
218		t.Fatal("a stale status unblocked the composer")
219	}
220	status.Revision = m.statusRevision
221	m, _ = m.Update(status)
222	m.TextArea.Reset()
223	if !strings.Contains(ansi.Strip(m.View()), "ctrl+g editor") {
224		t.Fatal("an idle status without a phase did not restore the input cue")
225	}
226	m.TextArea.SetValue(draft)
227	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
228	pending := m.pendingUsers
229	if m.TextArea.Value() != "" || len(pending) != 1 || pending[0].Text != draft {
230		t.Fatal("an idle status without a phase refused the draft")
231	}
232}