package tui import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "agentu/internal/agent" "agentu/pkg/llm" 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"} { 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 TestInputTextBackgroundMatchesInputBox(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) want := lightTheme().InputBg for name, color := range map[string]lipgloss.TerminalColor{ "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(), } { requireStyleColor(t, name, color, want) } } 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 be 2 with vertical padding, got %d", got) } if got := m.styles.InputBox.GetHorizontalFrameSize(); got != 3 { t.Fatalf("input box horizontal frame should be 3 (asymmetric padding), got %d", got) } if got, want := m.input.Width(), 100-m.styles.InputBox.GetHorizontalFrameSize()-m.inputModeWidth()-1; 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() if !strings.Contains(rendered, "test-model") { t.Fatalf("rendered view missing %q:\n%s", "test-model", rendered) } } func TestModelMetaRendersContextUsage(t *testing.T) { assistant := agent.New(agent.Options{Model: "test-model", MaxContextTokens: 1000}) assistant.SetMessages([]llm.Message{{Role: llm.RoleUser, Content: strings.Repeat("x", 800)}}) m := newModel(context.Background(), assistant, Options{ModelName: "test-model"}) next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 30}) rendered := next.(model).footerView() for _, want := range []string{"ctx ~", "/1k", "%"} { if !strings.Contains(rendered, want) { t.Fatalf("model meta missing %q:\n%s", want, 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{iconWorking, "Working", "thinking...", "esc cancels"} { if !strings.Contains(rendered, want) { t.Fatalf("running view missing %q:\n%s", want, rendered) } } if backgroundPattern.MatchString(m.activityView()) { t.Fatalf("activity view should not paint a background:\n%q", m.activityView()) } } 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 TestCommandPaletteFuzzyMatches(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("/mdl") m.afterInputChanged() plain := stripANSI(m.View()) for _, want := range []string{iconCommand + " Commands", "/model model/provider/thinking"} { if !strings.Contains(plain, want) { t.Fatalf("rendered palette missing %q:\n%s", want, plain) } } matches := commandMatches("m") modelIndex := -1 renameIndex := -1 for i, match := range matches { switch match.command.Name { case "/model": modelIndex = i case "/rename": renameIndex = i } } if modelIndex < 0 || renameIndex < 0 || modelIndex > renameIndex { t.Fatalf("/model should sort before /rename for /m query: %#v", matches) } } func TestInputHistoryNavigation(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.rememberInput("first") m.rememberInput("second") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) m = next.(model) if got := m.input.Value(); got != "second" { t.Fatalf("after ctrl+p input = %q", got) } next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlP}) m = next.(model) if got := m.input.Value(); got != "first" { t.Fatalf("after second ctrl+p input = %q", got) } next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) m = next.(model) if got := m.input.Value(); got != "second" { t.Fatalf("after ctrl+n input = %q", got) } next, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) m = next.(model) if got := m.input.Value(); got != "" { t.Fatalf("after second ctrl+n input = %q", got) } } func TestUpArrowRecallsPreviousInput(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.rememberInput("first") m.rememberInput("second") next, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) m = next.(model) if got := m.input.Value(); got != "second" { t.Fatalf("after first up input = %q, want %q", got, "second") } next, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) m = next.(model) if got := m.input.Value(); got != "first" { t.Fatalf("after second up input = %q, want %q", got, "first") } } func TestToolMessageRendersStructuredCard(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`}) plain := stripANSI(m.renderMessages()) for _, want := range []string{iconTool + " file_read", "README.md"} { if !strings.Contains(plain, want) { t.Fatalf("tool card missing %q:\n%s", want, plain) } } } func TestToolMessageRendersOutputPreview(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.messages = append(m.messages, message{role: roleTool, content: "file_read {\"path\":\"README.md\"}\n[output]\nfirst line\nsecond line"}) plain := stripANSI(m.renderMessages()) for _, want := range []string{iconTool + " file_read", "README.md", "output", "first line", "second line"} { if !strings.Contains(plain, want) { t.Fatalf("tool card missing %q:\n%s", want, plain) } } } func TestToolLogOutputUpdatesExistingToolCard(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) m.messages = append(m.messages, message{role: roleTool, content: `file_read {"path":"README.md"}`}) updated := m.mergeToolLog("file_read {\"path\":\"README.md\"}\n[output]\nhello") if !updated { t.Fatal("expected mergeToolLog to return true") } if len(m.messages) != 1 { t.Fatalf("messages count = %d, want 1", len(m.messages)) } plain := stripANSI(m.renderMessages()) if !strings.Contains(plain, "hello") { t.Fatalf("rendered output missing 'hello':\n%s", plain) } status := toolStatusText("file_read {\"path\":\"README.md\"}\n[output]\nhello world") if strings.Contains(status, "hello") { t.Fatalf("status should not contain output: %q", status) } if !strings.Contains(status, "file_read") { t.Fatalf("status missing tool name: %q", status) } } type tuiCompactProvider struct{} func (tuiCompactProvider) ChatStream(ctx context.Context, req llm.ChatRequest, emit func(llm.StreamEvent) error) error { return emit(llm.StreamEvent{Content: "summary from tui"}) } func TestCompactCommand(t *testing.T) { assistant := agent.New(agent.Options{Provider: tuiCompactProvider{}, Model: "test", SystemPrompt: "system"}) assistant.SetMessages([]llm.Message{ {Role: llm.RoleSystem, Content: "system"}, {Role: llm.RoleUser, Content: "old question 1"}, {Role: llm.RoleAssistant, Content: "old answer 1"}, {Role: llm.RoleUser, Content: "old question 2"}, {Role: llm.RoleAssistant, Content: "old answer 2"}, {Role: llm.RoleUser, Content: "recent question 1"}, {Role: llm.RoleAssistant, Content: "recent answer 1"}, {Role: llm.RoleUser, Content: "recent question 2"}, {Role: llm.RoleAssistant, Content: "recent answer 2"}, }) m := newModel(context.Background(), assistant, Options{ModelName: "test-model"}) out, err := m.handleLocalCommand("/compact") if err != nil { t.Fatal(err) } if out != "Context compacted." { t.Fatalf("output = %q", out) } if !strings.Contains(messageContents(assistant.Messages()), "[context summary]") { t.Fatalf("messages missing summary: %#v", assistant.Messages()) } } func messageContents(messages []llm.Message) string { var b strings.Builder for _, msg := range messages { b.WriteString(msg.Content) b.WriteByte('\n') } return b.String() } func TestWorkflowStatusDiffAndTestCommands(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git not installed") } dir := t.TempDir() runInDir(t, dir, "git", "init") runInDir(t, dir, "git", "config", "user.email", "agentu@example.test") runInDir(t, dir, "git", "config", "user.name", "agentu") if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/p1\n\ngo 1.26\n"), 0o644); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "main_test.go"), []byte("package p1\n\nimport \"testing\"\n\nfunc TestOK(t *testing.T) {}\n"), 0o644); err != nil { t.Fatal(err) } runInDir(t, dir, "git", "add", ".") runInDir(t, dir, "git", "commit", "-m", "init") if err := os.WriteFile(filepath.Join(dir, "main_test.go"), []byte("package p1\n\nimport \"testing\"\n\nfunc TestOK(t *testing.T) { t.Log(\"changed\") }\n"), 0o644); err != nil { t.Fatal(err) } m := newModel(context.Background(), nil, Options{ModelName: "test-model", WorkingDir: dir}) status, err := m.handleStatusCommand(nil) if err != nil { t.Fatal(err) } if !strings.Contains(status, "main_test.go") { t.Fatalf("status missing changed file:\n%s", status) } diff, err := m.handleDiffCommand(nil) if err != nil { t.Fatal(err) } if !strings.Contains(diff, "changed") { t.Fatalf("diff missing changed content:\n%s", diff) } out, err := m.handleTestCommand(nil) if err != nil { t.Fatal(err) } if !strings.Contains(out, "$ go test ./...") { t.Fatalf("test output missing default command:\n%s", out) } } func runInDir(t *testing.T, dir string, name string, args ...string) { t.Helper() cmd := exec.Command(name, args...) cmd.Dir = dir if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%s %v failed: %v\n%s", name, args, err, output) } } func TestBangShellCommandRunsFromInput(t *testing.T) { dir := t.TempDir() m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true, WorkingDir: dir}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("! printf hello") next, _ = m.submit() m = next.(model) if m.input.Value() != "" { t.Fatalf("input not cleared after bang command: %q", m.input.Value()) } rendered := stripANSI(m.renderMessages()) if !strings.Contains(rendered, "$ printf hello") { t.Fatalf("rendered output missing shell echo:\n%s", rendered) } if !strings.Contains(rendered, "hello") { t.Fatalf("rendered output missing 'hello':\n%s", rendered) } } func TestBangShellCommandRequiresYolo(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: false}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("! printf hello") next, _ = m.submit() m = next.(model) rendered := stripANSI(m.renderMessages()) if !strings.Contains(rendered, "shell command input requires --yolo") { t.Fatalf("rendered output missing yolo requirement:\n%s", rendered) } } func TestBangShellCommandRejectsEmptyCommand(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("!") next, _ = m.submit() m = next.(model) rendered := stripANSI(m.renderMessages()) if !strings.Contains(rendered, "usage: ! ") { t.Fatalf("rendered output missing usage hint:\n%s", 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() if !strings.Contains(rendered, "test-model") { t.Fatalf("model command output missing %q:\n%s", "test-model", 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) } } func TestModelCommandPaletteShowsConfiguredModels(t *testing.T) { manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}} m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("/model ") m.afterInputChanged() plain := stripANSI(m.View()) for _, want := range []string{iconCommand + " Model candidates", "model-a", "current model", "model-b", "switch model"} { if !strings.Contains(plain, want) { t.Fatalf("rendered palette missing %q:\n%s", want, plain) } } } func TestModelCommandPaletteSelectsConfiguredModel(t *testing.T) { manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}} m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("/model ") m.afterInputChanged() next, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) m = next.(model) next, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) m = next.(model) if manager.model != "model-b" { t.Fatalf("model = %q", manager.model) } if got := m.input.Value(); got != "" { t.Fatalf("input = %q", got) } rendered := stripANSI(m.renderMessages()) if !strings.Contains(rendered, "model: model-b") { t.Fatalf("rendered output missing model switch:\n%s", rendered) } } func TestModelCommandPaletteFiltersBareModelPartial(t *testing.T) { manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}} m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("/model deep") m.afterInputChanged() palette := stripANSI(m.commandPaletteView()) if !strings.Contains(palette, "deepseek-v4-flash") { t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette) } if strings.Contains(palette, "claude-sonnet") { t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette) } } func TestModelCommandPaletteFiltersExplicitModelSubcommand(t *testing.T) { manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}} m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("/model model deep") m.afterInputChanged() palette := stripANSI(m.commandPaletteView()) if !strings.Contains(palette, "deepseek-v4-flash") { t.Fatalf("expected deepseek-v4-flash in palette:\n%s", palette) } if strings.Contains(palette, "claude-sonnet") { t.Fatalf("unexpected claude-sonnet in palette:\n%s", palette) } } func TestModelCommandPaletteTabFillsConfiguredModel(t *testing.T) { manager := &fakeModelManager{model: "claude-sonnet", models: []string{"claude-sonnet", "deepseek-v4-flash"}} m := newModel(context.Background(), nil, Options{ModelName: "claude-sonnet", ModelManager: manager}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("/model deep") m.afterInputChanged() next, _ = m.Update(tea.KeyMsg{Type: tea.KeyTab}) m = next.(model) if got := m.input.Value(); got != "/model model deepseek-v4-flash" { t.Fatalf("input = %q", got) } if manager.model != "claude-sonnet" { t.Fatalf("model should not change on tab: %q", manager.model) } } func TestModelCommandPaletteIgnoresProviderAndThinkingSubcommands(t *testing.T) { manager := &fakeModelManager{model: "model-a", models: []string{"model-a", "model-b"}} m := newModel(context.Background(), nil, Options{ModelName: "model-a", ModelManager: manager}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) m.input.SetValue("/model provider ") m.afterInputChanged() if sugs := m.commandSuggestions(); len(sugs) != 0 { t.Fatalf("provider suggestions should be empty, got %d", len(sugs)) } if view := m.commandPaletteView(); view != "" { t.Fatalf("provider palette should be empty:\n%s", view) } m.input.SetValue("/model thinking ") m.afterInputChanged() if sugs := m.commandSuggestions(); len(sugs) != 0 { t.Fatalf("thinking suggestions should be empty, got %d", len(sugs)) } if view := m.commandPaletteView(); view != "" { t.Fatalf("thinking palette should be empty:\n%s", view) } } type fakeModelManager struct { provider string model string thinking string sessionName string models []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 (f *fakeModelManager) AvailableModels() []string { if len(f.models) > 0 { return append([]string(nil), f.models...) } if f.model != "" { return []string{f.model} } return 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 requireStyleColor(t *testing.T, name string, got lipgloss.TerminalColor, want string) { t.Helper() color, ok := got.(lipgloss.Color) if !ok { t.Fatalf("%s = %T, want lipgloss.Color", name, got) } if string(color) != want { t.Fatalf("%s = %q, want %q", name, string(color), want) } } func requireNoStyleColor(t *testing.T, name string, got lipgloss.TerminalColor) { t.Helper() if _, ok := got.(lipgloss.NoColor); !ok { t.Fatalf("%s should not paint a color, got %T", name, got) } } func TestDarkThemePaintsAppBackground(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) rendered := stripANSI(m.View()) _ = rendered if m.theme.Mode != ThemeDark { t.Fatalf("theme.Mode = %q, want %q", m.theme.Mode, ThemeDark) } want := darkTheme() requireStyleColor(t, "App.Background", m.styles.App.GetBackground(), want.Background) requireStyleColor(t, "App.Foreground", m.styles.App.GetForeground(), want.Text) } func TestDarkThemeKeepsComponentBackgroundsLightweight(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark}) next, _ := m.Update(tea.WindowSizeMsg{Width: 100, Height: 30}) m = next.(model) requireNoStyleColor(t, "Viewport.Background", m.styles.Viewport.GetBackground()) requireStyleColor(t, "InputBox.Background", m.styles.InputBox.GetBackground(), darkTheme().InputBg) requireNoStyleColor(t, "Activity.Background", m.styles.Activity.GetBackground()) m.running = true m.status = "thinking..." m.resize() if backgroundPattern.MatchString(m.activityView()) { t.Fatalf("activity view should not paint a background:\n%q", m.activityView()) } } func TestDarkThemeStylesUseDarkPalette(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", ThemeMode: ThemeDark}) want := darkTheme() requireStyleColor(t, "Header.Foreground", m.styles.Header.GetForeground(), want.Title) requireStyleColor(t, "Muted.Foreground", m.styles.Muted.GetForeground(), want.Muted) requireStyleColor(t, "Pill.Background", m.styles.Pill.GetBackground(), want.SurfaceAlt) requireStyleColor(t, "input.FocusedStyle.Base.Foreground", m.input.FocusedStyle.Base.GetForeground(), want.Text) requireStyleColor(t, "input.FocusedStyle.Placeholder.Foreground", m.input.FocusedStyle.Placeholder.GetForeground(), want.Muted) requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), want.Error) requireStyleColor(t, "InputModeCommand.Foreground", m.styles.InputModeCommand.GetForeground(), want.User) } func TestInputModeSymbolReflectsYoloAndBangCommand(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 := stripANSI(m.inputView()) if !strings.Contains(rendered, "Y") { t.Fatalf("YOLO input view should contain Y symbol:\n%s", rendered) } m.input.SetValue("! pwd") m.afterInputChanged() rendered = stripANSI(m.inputView()) if !strings.Contains(rendered, iconCommand) { t.Fatalf("bang input view should contain command icon:\n%s", rendered) } if strings.Contains(rendered, "Y") { t.Fatalf("bang input view should not contain Y symbol:\n%s", rendered) } } func TestInputModeYoloUsesWarningColor(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model", Yolo: true}) requireStyleColor(t, "InputModeYolo.Foreground", m.styles.InputModeYolo.GetForeground(), lightTheme().Error) } func TestThemeSlashCommandSwitchesAtRuntime(t *testing.T) { m := newModel(context.Background(), nil, Options{ModelName: "test-model"}) if m.theme.Mode != ThemeLight { t.Fatalf("initial theme = %q, want light", m.theme.Mode) } requireNoStyleColor(t, "initial App.Background", m.styles.App.GetBackground()) // Switch to dark m.input.SetValue("/theme dark") next, _ := m.submit() m = next.(model) if m.theme.Mode != ThemeDark { t.Fatalf("after /theme dark: Mode = %q, want dark", m.theme.Mode) } requireStyleColor(t, "dark App.Background", m.styles.App.GetBackground(), darkTheme().Background) last := m.messages[len(m.messages)-1] if !strings.Contains(last.content, "theme: dark") { t.Fatalf("expected confirmation message, got %q", last.content) } // Switch back to light m.input.SetValue("/theme light") next, _ = m.submit() m = next.(model) if m.theme.Mode != ThemeLight { t.Fatalf("after /theme light: Mode = %q, want light", m.theme.Mode) } requireNoStyleColor(t, "restored App.Background", m.styles.App.GetBackground()) // No arg shows usage m.input.SetValue("/theme") next, _ = m.submit() m = next.(model) last = m.messages[len(m.messages)-1] if !strings.Contains(last.content, "usage: /theme") { t.Fatalf("expected usage hint, got %q", last.content) } } 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) } }