86f5ca4aef
Add visible activity state for running turns. Move model metadata below the input area. Render assistant Markdown with theme-aware styling.
267 lines
7.8 KiB
Go
267 lines
7.8 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
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})
|
|
rendered := next.(model).View()
|
|
|
|
for _, want := range []string{"agentu", "test-model", "yolo tools", "light", "ctrl+j", "alt+enter"} {
|
|
if !strings.Contains(rendered, want) {
|
|
t.Fatalf("rendered view missing %q:\n%s", want, 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 TestUserMessageRendersOnLeft(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, "hello", 80)
|
|
if strings.HasPrefix(rendered, " ") {
|
|
t.Fatalf("user message should not be right-aligned:\n%q", rendered)
|
|
}
|
|
if !strings.Contains(rendered, "You") || !strings.Contains(rendered, "hello") {
|
|
t.Fatalf("user message missing content:\n%s", rendered)
|
|
}
|
|
}
|
|
|
|
func TestAssistantMessagesRenderMarkdown(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{"Agentu", "Title", "item"} {
|
|
if !strings.Contains(rendered, want) {
|
|
t.Fatalf("assistant markdown missing %q:\n%s", want, rendered)
|
|
}
|
|
}
|
|
if strings.Contains(rendered, "# Title") {
|
|
t.Fatalf("assistant markdown heading was not rendered:\n%s", 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 TestMessageRoleLabels(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{"You", "Agentu"} {
|
|
if !strings.Contains(rendered, want) {
|
|
t.Fatalf("messages missing %q:\n%s", want, 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
|
|
}
|
|
|
|
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) 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 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)
|
|
}
|
|
}
|