package tui import ( "context" "strings" "testing" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) func TestModelRendersChatShell(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) rendered := m.View() for _, want := range []string{"test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} { if !strings.Contains(rendered, want) { t.Fatalf("rendered view missing %q:\n%s", want, rendered) } } if strings.Contains(rendered, "local agent") { t.Fatalf("top header should not render:\n%s", rendered) } if _, ok := m.styles.App.GetBackground().(lipgloss.NoColor); !ok { t.Fatalf("app should not paint a global background") } if _, ok := m.styles.Viewport.GetBackground().(lipgloss.NoColor); !ok { t.Fatalf("viewport should not paint an assistant background") } } func TestInputTextDoesNotPaintBackground(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) for name, color := range map[string]any{ "input box": m.styles.InputBox.GetBackground(), "focused base": m.input.FocusedStyle.Base.GetBackground(), "focused cursor line": m.input.FocusedStyle.CursorLine.GetBackground(), "blurred base": m.input.BlurredStyle.Base.GetBackground(), "blurred cursor line": m.input.BlurredStyle.CursorLine.GetBackground(), } { if _, ok := color.(lipgloss.NoColor); !ok { t.Fatalf("%s should not paint input text background", name) } } } func TestInputBoxIsRoomier(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) if got := m.styles.InputBox.GetVerticalFrameSize(); got != 2 { t.Fatalf("input box vertical frame should stay compact, got %d", got) } if got := m.styles.InputBox.GetHorizontalFrameSize(); got < 6 { t.Fatalf("input box horizontal frame should include roomier padding, got %d", got) } if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize(); got != want { t.Fatalf("input content width = %d, want %d", got, want) } rendered := m.inputView() line := strings.Split(rendered, "\n")[0] if got := lipgloss.Width(line); got != 100 { t.Fatalf("input box rendered width = %d, want 100:\n%s", got, rendered) } } func TestModelMetaRendersBelowInput(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) rendered := next.(model).View() inputIndex := strings.Index(rendered, "Message agentu") modelIndex := strings.Index(rendered, "test-model") if inputIndex < 0 || modelIndex < 0 { t.Fatalf("rendered view missing input or model meta:\n%s", rendered) } if modelIndex < inputIndex { t.Fatalf("model meta should render below input:\n%s", rendered) } } func TestInitialViewDoesNotRenderTopNote(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) rendered := next.(model).View() for _, unwanted := range []string{"Note", "Ask anything", "Start a conversation"} { if strings.Contains(rendered, unwanted) { t.Fatalf("initial view should not render %q:\n%s", unwanted, rendered) } } } func TestRunningStateRendersActivityIndicator(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.running = true m.status = "thinking..." m.resize() rendered := m.View() for _, want := range []string{"Working", "thinking...", "esc cancels"} { if !strings.Contains(rendered, want) { t.Fatalf("running view missing %q:\n%s", want, rendered) } } } func TestUserMessageRendersFullWidthBubble(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) rendered := m.messageView(roleUser, "hi", 80) if !strings.Contains(rendered, "hi") { t.Fatalf("user message missing content:\n%s", rendered) } if strings.Contains(rendered, "You") { t.Fatalf("user message should not render label:\n%s", rendered) } if !strings.Contains(rendered, " hi") || !strings.Contains(rendered, "hi ") { t.Fatalf("user message should have horizontal padding:\n%q", rendered) } lines := strings.Split(rendered, "\n") if len(lines) < 3 { t.Fatalf("user message background should include vertical padding:\n%q", rendered) } for _, line := range lines { if got := lipgloss.Width(line); got != 80 { t.Fatalf("user message background should span full width, line width = %d:\n%q", got, rendered) } } m.width = 80 m.messages = []message{{role: roleUser, content: "hi"}} rendered = m.renderMessages() for _, line := range strings.Split(rendered, "\n") { if got := lipgloss.Width(line); got != 80 { t.Fatalf("rendered user transcript should span full width, line width = %d:\n%q", got, rendered) } } } func TestAssistantMessagesRenderMarkdownWithoutChrome(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) rendered := m.messageView(roleAssistant, "# Title\n\n- item", 80) for _, want := range []string{"Title", "item"} { if !strings.Contains(rendered, want) { t.Fatalf("assistant markdown missing %q:\n%s", want, rendered) } } for _, unwanted := range []string{"Agentu", "│", "┃", "|"} { if strings.Contains(rendered, unwanted) { t.Fatalf("assistant message should not render chrome %q:\n%s", unwanted, rendered) } } if strings.Contains(rendered, "# Title") { t.Fatalf("assistant markdown heading was not rendered:\n%s", rendered) } } func TestAssistantMessageRendersPaddedBodyWithoutBackground(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) if _, ok := m.styles.AssistantMsg.GetBackground().(lipgloss.NoColor); !ok { t.Fatalf("assistant message should not set a background color") } rendered := m.messageView(roleAssistant, "hello", 80) if !strings.Contains(rendered, "hello") { t.Fatalf("assistant message missing content:\n%s", rendered) } lines := strings.Split(rendered, "\n") if len(lines) < 3 { t.Fatalf("assistant message should include vertical padding:\n%q", rendered) } if got := lipgloss.Width(rendered); got <= len("hello") { t.Fatalf("assistant message should be wider than text, width = %d:\n%q", got, rendered) } for _, line := range lines { if got := lipgloss.Width(line); got >= 80 { t.Fatalf("assistant message should not fill the row, line width = %d:\n%q", got, rendered) } } } func TestUserMessagesDoNotRenderMarkdown(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) rendered := m.messageView(roleUser, "# Title\n\n- item", 80) for _, want := range []string{"# Title", "- item"} { if !strings.Contains(rendered, want) { t.Fatalf("user markdown should stay plain and include %q:\n%s", want, rendered) } } } func TestMessageRoleLabelsDoNotLabelUserOrAssistant(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.messages = []message{ {role: roleUser, content: "hello"}, {role: roleAssistant, content: "hi"}, } rendered := m.renderMessages() for _, want := range []string{"hello", "hi"} { if !strings.Contains(rendered, want) { t.Fatalf("messages missing content %q:\n%s", want, rendered) } } for _, unwanted := range []string{"You", "Agentu"} { if strings.Contains(rendered, unwanted) { t.Fatalf("message should not render label %q:\n%s", unwanted, rendered) } } } func TestCtrlJInsertsInputNewline(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.input.SetValue("hello") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ}) got := next.(model).input.Value() if got != "hello\n" { t.Fatalf("input value = %q", got) } } func TestInputStartsAtOneLineAndGrows(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) if got := m.input.Height(); got != 1 { t.Fatalf("initial input height = %d", got) } m.input.SetValue("one\ntwo\nthree") m.afterInputChanged() if got := m.input.Height(); got != 3 { t.Fatalf("multiline input height = %d", got) } m.input.SetValue(strings.Repeat("line\n", 20)) m.afterInputChanged() if got := m.input.Height(); got != maxInputLines { t.Fatalf("clamped input height = %d", got) } } func TestSlashCommandSuggestions(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("/") m.afterInputChanged() rendered := m.View() for _, want := range []string{"/clear reset context", "/exit quit", "/model model/provider/thinking"} { if !strings.Contains(rendered, want) { t.Fatalf("rendered suggestions missing %q:\n%s", want, rendered) } } } func TestModelCommand(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) m.input.SetValue("/model") next, _ := m.submit() rendered := next.(model).renderMessages() for _, want := range []string{"model: test-model", "mode: yolo tools", "theme: light"} { if !strings.Contains(rendered, want) { t.Fatalf("model command output missing %q:\n%s", want, rendered) } } } func TestModelThinkingCommand(t *testing.T) { manager := &fakeModelManager{model: "test-model", thinking: "none"} m := newModel(context.Background(), nil, Options{ModelName: "test-model", ModelManager: manager}) m.input.SetValue("/model thinking high") next, _ := m.submit() rendered := next.(model).renderMessages() if manager.thinking != "high" { t.Fatalf("thinking = %q", manager.thinking) } if !strings.Contains(rendered, "thinking: high") { t.Fatalf("model command output missing thinking:\n%s", rendered) } } type fakeModelManager struct { provider string model string thinking string sessionName string saved bool } func (f *fakeModelManager) CurrentProvider() string { if f.provider == "" { return "default" } return f.provider } func (f *fakeModelManager) CurrentModel() string { return f.model } func (f *fakeModelManager) CurrentThinking() string { return f.thinking } func (f *fakeModelManager) CurrentSessionID() string { return "test-id" } func (f *fakeModelManager) CurrentSessionName() string { if f.sessionName == "" { return "test-session" } return f.sessionName } func (f *fakeModelManager) Info() string { return "provider: " + f.CurrentProvider() + "\nmodel: " + f.model + "\nthinking: " + f.thinking } func (f *fakeModelManager) SetProvider(name string) (string, error) { f.provider = name return f.Info(), nil } func (f *fakeModelManager) SetModel(model string) (string, error) { f.model = model return f.Info(), nil } func (f *fakeModelManager) SetThinking(level string) (string, error) { f.thinking = level return f.Info(), nil } func (f *fakeModelManager) Save() error { f.saved = true return nil } func (f *fakeModelManager) Resume(id string) error { return nil } func (f *fakeModelManager) Rename(name string) (string, error) { f.sessionName = name return "Renamed.", nil } func (f *fakeModelManager) Sessions() (string, error) { return "No sessions.", nil } func (f *fakeModelManager) NewSession() (string, error) { return "New session.", nil } func TestParseThemeMode(t *testing.T) { for input, want := range map[string]ThemeMode{ "": ThemeLight, "light": ThemeLight, "dark": ThemeDark, "DARK": ThemeDark, } { got, err := ParseThemeMode(input) if err != nil { t.Fatalf("ParseThemeMode(%q): %v", input, err) } if got != want { t.Fatalf("ParseThemeMode(%q) = %q, want %q", input, got, want) } } if _, err := ParseThemeMode("sepia"); err == nil { t.Fatal("expected invalid theme error") } } func TestCleanToolLog(t *testing.T) { got := cleanToolLog("\n[tool] shell_run {\"command\":\"pwd\"}\n") want := `shell_run {"command":"pwd"}` if got != want { t.Fatalf("got %q want %q", got, want) } }