Files
loveuer c8d8329dbb feat(tui): codex-style input, history, bang commands, single-line status bar
- Arrow-key input history (up/down with multiline awareness)
- Bang shell commands via ! prefix (requires --yolo)
- Codex-style input bar with mode indicator (R/Y/⌘)
- Command palette virtual scrolling
- Multiline input viewport fix
- Merged model pills + footer into single status line
- Removed shift+enter (wait for TUI library support)
- Removed default footer hints
2026-06-25 20:40:50 -07:00

915 lines
31 KiB
Go

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: ! <command>") {
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)
}
}