ollama/cmd/tui/chat/input_test.go
2026-06-24 14:59:45 -07:00

1099 lines
34 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package chat
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
coreagent "github.com/ollama/ollama/agent"
agentskills "github.com/ollama/ollama/agent/skills"
"github.com/ollama/ollama/api"
)
func TestChatHelpCommandShowsCommands(t *testing.T) {
m := chatModel{input: []rune("/help")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("help command should not return a command")
}
fm := updated.(chatModel)
if len(fm.entries) != 1 {
t.Fatalf("entries = %d, want 1", len(fm.entries))
}
if !strings.Contains(fm.entries[0].content, "**Commands**") ||
!strings.Contains(fm.entries[0].content, "- `/model`: switch models") ||
!strings.Contains(fm.entries[0].content, "- `/think`: set thinking mode") ||
!strings.Contains(fm.entries[0].content, "- `/verbose`: toggle model metrics") ||
!strings.Contains(fm.entries[0].content, "- `/<skill>`: run the next message with a skill") ||
!strings.Contains(fm.entries[0].content, "- `/help`: show commands") ||
!strings.Contains(fm.entries[0].content, "- `/bye`: exit") ||
!strings.Contains(fm.entries[0].content, "**Shortcuts**") ||
!strings.Contains(fm.entries[0].content, "- `ctrl+o`: toggle tool output") ||
!strings.Contains(fm.entries[0].content, "- `shift+enter`: insert a newline") ||
!strings.Contains(fm.entries[0].content, "- `shift+tab`: toggle permission mode") ||
!strings.Contains(fm.entries[0].content, "- `ctrl+a/e`: move to line start or end") {
t.Fatalf("help output = %q", fm.entries[0].content)
}
for _, hidden := range []string{"/history", "/raw", "/set think", "/set nothink"} {
if strings.Contains(fm.entries[0].content, hidden) {
t.Fatalf("hidden command %q should stay hidden from help: %q", hidden, fm.entries[0].content)
}
}
}
func TestTruncateInputLineUsesDisplayWidth(t *testing.T) {
line := truncateInputLine(strings.Repeat("界", 10), 10)
if got := lipgloss.Width(line); got > 10 {
t.Fatalf("line %q width = %d, want <= 10", line, got)
}
}
func TestRenderInputBoxTruncationUsesSingleContinuationMarker(t *testing.T) {
lines := renderInputBoxLines("one two three four five six seven", len("one two three four five six seven"), 16, 1, "")
rendered := strings.Join(lines, "\n")
if strings.Contains(rendered, "... ...") {
t.Fatalf("input rendered duplicate continuation marker: %q", rendered)
}
if !strings.Contains(rendered, "...") {
t.Fatalf("input should include continuation marker: %q", rendered)
}
}
type shiftEnterCSITestMsg string
func (m shiftEnterCSITestMsg) String() string {
return string(m)
}
func TestChatInputHandlesShiftEnterCSIMessage(t *testing.T) {
m := chatModel{input: []rune("line one")}
updated, _ := m.Update(shiftEnterCSITestMsg("?CSI[49 51 59 50 117]?"))
m = updated.(chatModel)
if got := string(m.input); got != "line one\n" {
t.Fatalf("input = %q, want newline inserted", got)
}
}
func TestChatInputAcceptsSpace(t *testing.T) {
m := chatModel{}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")})
m = updated.(chatModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")})
m = updated.(chatModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("world")})
m = updated.(chatModel)
if got := string(m.input); got != "hello world" {
t.Fatalf("input = %q, want hello world", got)
}
}
func TestChatLargePasteUsesPlaceholderAndExpandsOnSubmit(t *testing.T) {
pasted := strings.Repeat("line\n", pastedTextPlaceholderMinLines-1) + "line"
m := chatModel{
ctx: context.Background(),
opts: Options{
Model: "test",
Client: chatTestClient{},
},
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(pasted), Paste: true})
m = updated.(chatModel)
if got, want := string(m.input), "[Pasted text #1 +8 lines]"; got != want {
t.Fatalf("input = %q, want %q", got, want)
}
if got := len(m.inputPastedTexts); got != 1 {
t.Fatalf("pasted texts = %d, want 1", got)
}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd == nil {
t.Fatal("submit should start a run")
}
if got := m.entries[0].content; got != "[Pasted text #1 +8 lines]" {
t.Fatalf("display content = %q, want placeholder", got)
}
if got := m.liveMessages[0].Content; got != pasted {
t.Fatalf("model content = %q, want pasted text", got)
}
if len(m.promptHistory) != 1 || m.promptHistory[0] != pasted {
t.Fatalf("prompt history = %#v, want expanded pasted text", m.promptHistory)
}
m.running = false
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(chatModel)
if got := string(m.input); got != "[Pasted text #2 +8 lines]" {
t.Fatalf("recalled input = %q, want pasted text placeholder", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if got := m.liveMessages[0].Content; got != pasted {
t.Fatalf("recalled model content = %q, want pasted text", got)
}
}
func TestChatBackspaceDeletesWholePastedTextPlaceholder(t *testing.T) {
pasted := strings.Repeat("long paste line\n", pastedTextPlaceholderMinLines)
m := chatModel{}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(pasted), Paste: true})
m = updated.(chatModel)
if got := len(m.inputPastedTexts); got != 1 {
t.Fatalf("pasted texts = %d, want 1", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
m = updated.(chatModel)
if got := string(m.input); got != "" {
t.Fatalf("input after backspace = %q, want empty", got)
}
if got := len(m.inputPastedTexts); got != 0 {
t.Fatalf("pasted texts after backspace = %d, want 0", got)
}
}
func TestChatWordBackspaceDeletesWholePastedTextPlaceholder(t *testing.T) {
pasted := strings.Repeat("long paste line\n", pastedTextPlaceholderMinLines)
m := chatModel{input: []rune("use ")}
m.inputCursor = len(m.input)
m.inputCursorSet = true
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(pasted), Paste: true})
m = updated.(chatModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(chatModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true})
m = updated.(chatModel)
if got := string(m.input); got != "use " {
t.Fatalf("input after word backspace = %q, want pasted text placeholder removed", got)
}
if got := len(m.inputPastedTexts); got != 0 {
t.Fatalf("pasted texts after word backspace = %d, want 0", got)
}
}
func TestChatEditorResultReplacesInput(t *testing.T) {
m := chatModel{
input: []rune("old"),
inputCursor: 3,
inputCursorSet: true,
inputPastedTexts: []chatInputPastedText{{placeholder: "[Pasted text #1 +8 lines]", content: "hidden"}},
}
m.applyEditorResult(chatEditorDoneMsg{content: "new prompt"})
if got := string(m.input); got != "new prompt" {
t.Fatalf("input = %q, want new prompt", got)
}
if got := m.inputCursor; got != len("new prompt") {
t.Fatalf("cursor = %d, want %d", got, len("new prompt"))
}
if got := len(m.inputPastedTexts); got != 0 {
t.Fatalf("pasted texts = %d, want 0 after editor removed placeholder", got)
}
}
func TestChatInputAcceptsTextWhileRunning(t *testing.T) {
m := chatModel{running: true}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("next")})
m = updated.(chatModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeySpace})
m = updated.(chatModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("prompt")})
m = updated.(chatModel)
if got := string(m.input); got != "next prompt" {
t.Fatalf("input = %q, want queued draft text", got)
}
}
func TestChatInputAltEnterInsertsNewline(t *testing.T) {
m := chatModel{input: []rune("line one")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter, Alt: true})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("alt+enter should not submit")
}
if got := string(m.input); got != "line one\n" {
t.Fatalf("input = %q, want newline appended", got)
}
}
func TestChatInputCtrlJInsertsNewline(t *testing.T) {
m := chatModel{input: []rune("line one")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlJ})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("ctrl+j should not submit")
}
if got := string(m.input); got != "line one\n" {
t.Fatalf("input = %q, want newline appended", got)
}
}
func TestChatInputOptionBackspaceDeletesPreviousWord(t *testing.T) {
m := chatModel{input: []rune("alpha beta ")}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true})
m = updated.(chatModel)
if got := string(m.input); got != "alpha " {
t.Fatalf("input = %q, want alpha with trailing space", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace, Alt: true})
m = updated.(chatModel)
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want empty", got)
}
}
func TestChatInputCtrlWDeletesPreviousWord(t *testing.T) {
m := chatModel{input: []rune("alpha beta")}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlW})
m = updated.(chatModel)
if got := string(m.input); got != "alpha " {
t.Fatalf("input = %q, want alpha with trailing space", got)
}
}
func TestChatInputCtrlUClearsPrompt(t *testing.T) {
m := chatModel{input: []rune("alpha beta")}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlU})
m = updated.(chatModel)
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want empty", got)
}
}
func TestChatInputArrowKeysEditMiddle(t *testing.T) {
m := chatModel{input: []rune("hello world")}
for range 5 {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyLeft})
m = updated.(chatModel)
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("beautiful ")})
m = updated.(chatModel)
if got := string(m.input); got != "hello beautiful world" {
t.Fatalf("input = %q, want inserted text in middle", got)
}
if m.inputCursor != len([]rune("hello beautiful ")) {
t.Fatalf("cursor = %d, want after inserted text", m.inputCursor)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyBackspace})
m = updated.(chatModel)
if got := string(m.input); got != "hello beautifulworld" {
t.Fatalf("input after backspace = %q", got)
}
}
func TestChatInputUpDownNavigateMultilineBeforeHistory(t *testing.T) {
m := chatModel{
input: []rune("one\ntwo\nthree"),
promptHistory: []string{"old prompt"},
}
m.inputCursor = len([]rune("one\ntwo"))
m.inputCursorSet = true
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(chatModel)
if got := m.inputCursor; got != len([]rune("one")) {
t.Fatalf("cursor after up = %d, want end of first line", got)
}
if got := string(m.input); got != "one\ntwo\nthree" {
t.Fatalf("input after up = %q, want unchanged", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(chatModel)
if got := m.inputCursor; got != len([]rune("one\ntwo")) {
t.Fatalf("cursor after down = %d, want end of second line", got)
}
if m.promptActive {
t.Fatal("multiline cursor navigation should not enter prompt history")
}
}
func TestChatPromptHistoryNavigatesPreviousPrompts(t *testing.T) {
m := chatModel{
input: []rune("draft"),
promptHistory: []string{"first prompt", "second prompt"},
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(chatModel)
if got := string(m.input); got != "second prompt" {
t.Fatalf("input after first up = %q, want second prompt", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(chatModel)
if got := string(m.input); got != "first prompt" {
t.Fatalf("input after second up = %q, want first prompt", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(chatModel)
if got := string(m.input); got != "second prompt" {
t.Fatalf("input after down = %q, want second prompt", got)
}
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(chatModel)
if got := string(m.input); got != "draft" {
t.Fatalf("input after returning to draft = %q, want draft", got)
}
if m.promptActive {
t.Fatal("prompt history should be inactive after returning to draft")
}
}
func TestChatPromptHistoryEditsRecalledPrompt(t *testing.T) {
m := chatModel{promptHistory: []string{"previous"}}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp})
m = updated.(chatModel)
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" edited")})
m = updated.(chatModel)
if got := string(m.input); got != "previous edited" {
t.Fatalf("edited input = %q, want previous edited", got)
}
if m.promptActive {
t.Fatal("editing recalled input should leave prompt history navigation")
}
}
func TestInitialPromptHistoryLoadsFromStore(t *testing.T) {
store := &chatResumeTestStore{prompts: []string{"old prompt", "new prompt"}}
history := initialPromptHistory(context.Background(), Options{
Store: store,
Messages: []api.Message{{Role: "user", Content: "fallback prompt"}},
})
if !slices.Equal(history, []string{"old prompt", "new prompt"}) {
t.Fatalf("history = %#v, want store prompts", history)
}
}
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
for _, command := range []string{"/copy", "/copy-all", "/tools", "/launch", "/system"} {
t.Run(command, func(t *testing.T) {
m := chatModel{input: []rune(command)}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("deleted slash command should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "Unknown command") {
t.Fatalf("entries = %#v, want unknown command error", m.entries)
}
})
}
}
func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
m := chatModel{
input: []rune("/"),
width: 80,
height: 18,
}
view := stripANSI(m.View())
if !strings.Contains(view, "/clear") || !strings.Contains(view, "clear this chat") {
t.Fatalf("view missing clear suggestion: %q", view)
}
if !strings.Contains(view, "/model") || !strings.Contains(view, "switch models") {
t.Fatalf("view missing model suggestion: %q", view)
}
if strings.Contains(view, "/copy") || strings.Contains(view, "/copy-all") || strings.Contains(view, "/tools") || strings.Contains(view, "/history") {
t.Fatalf("bare slash should hide utility commands: %q", view)
}
if got := len(m.slashCommandLines(80)); got != maxSlashCompletions {
t.Fatalf("slash suggestions = %d, want %d", got, maxSlashCompletions)
}
if !strings.Contains(view, " /█") {
t.Fatalf("view missing slash input row: %q", view)
}
}
func TestChatSlashCommandSuggestionsScroll(t *testing.T) {
m := chatModel{
input: []rune("/"),
}
if got := len(m.slashCompletions()); got <= maxSlashCompletions {
t.Fatalf("test setup needs more than %d slash completions, got %d", maxSlashCompletions, got)
}
for range 4 {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(chatModel)
}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if got := len(m.slashCommandLines(80)); got != maxSlashCompletions {
t.Fatalf("rendered slash suggestions = %d, want %d", got, maxSlashCompletions)
}
if !strings.Contains(lines, "/resume") {
t.Fatalf("scrolled suggestions missing later command: %q", lines)
}
if strings.Contains(lines, "/copy") {
t.Fatalf("scrolled suggestions should not include first command: %q", lines)
}
if selected, ok := m.selectedSlashCommand(); !ok || selected != "/resume" {
t.Fatalf("selected slash command = %q, %v; want /resume, true", selected, ok)
}
}
func TestChatSlashCommandSuggestionsOmitDeletedToolsCommand(t *testing.T) {
m := chatModel{
input: []rune("/to"),
}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if strings.Contains(lines, "/tools") || !strings.Contains(lines, "No matching commands") {
t.Fatalf("deleted /tools command should not be suggested: %q", lines)
}
}
func TestChatSlashCommandSuggestionsIncludeVerbose(t *testing.T) {
m := chatModel{
input: []rune("/ve"),
}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if !strings.Contains(lines, "/verbose") || !strings.Contains(lines, "toggle model metrics") {
t.Fatalf("suggestions missing /verbose: %q", lines)
}
}
func TestChatSlashCommandSuggestionsIncludeThink(t *testing.T) {
m := chatModel{
input: []rune("/th"),
}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if !strings.Contains(lines, "/think") || !strings.Contains(lines, "set thinking mode") {
t.Fatalf("suggestions missing /think: %q", lines)
}
}
func TestChatSlashCommandSuggestionsOmitDeletedSystemCommand(t *testing.T) {
m := chatModel{
input: []rune("/sys"),
}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if strings.Contains(lines, "/system") || !strings.Contains(lines, "No matching commands") {
t.Fatalf("deleted /system command should not be suggested: %q", lines)
}
}
func TestChatSlashCommandSuggestionsHideLegacySetThink(t *testing.T) {
m := chatModel{
input: []rune("/set"),
}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if strings.Contains(lines, "/set think") || strings.Contains(lines, "/set nothink") {
t.Fatalf("legacy think commands should not be suggested: %q", lines)
}
if !strings.Contains(lines, "No matching commands") {
t.Fatalf("legacy /set should remain hidden from suggestions: %q", lines)
}
}
func TestChatSlashCommandSuggestionsHideLegacyRunCommands(t *testing.T) {
for _, input := range []string{"/show", "/load"} {
t.Run(input, func(t *testing.T) {
m := chatModel{input: []rune(input)}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if strings.Contains(lines, input) || !strings.Contains(lines, "No matching commands") {
t.Fatalf("legacy command %s should stay hidden from suggestions: %q", input, lines)
}
})
}
}
func TestChatEnterAcceptsSelectedSlashCommand(t *testing.T) {
m := chatModel{
input: []rune("/"),
complete: 3, // /new
}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("slash command should not return a command")
}
if len(m.entries) != 0 || len(m.messages) != 0 {
t.Fatalf("new chat command should reset chat, entries=%d messages=%d", len(m.entries), len(m.messages))
}
}
func TestChatSlashCommandOutputRendersWithSeparator(t *testing.T) {
m := chatModel{
input: []rune("/skills"),
width: 80,
height: 16,
entries: []chatEntry{
{role: "assistant", content: "Hello!"},
},
}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("skills command should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 2 || m.entries[1].role != "slash" {
t.Fatalf("entries = %#v, want slash output entry", m.entries)
}
view := stripANSI(m.View())
if !strings.Contains(view, "• No skills are installed.") {
t.Fatalf("slash output should render with a separator dot:\n%s", view)
}
}
func TestChatThinkCommandOpensPicker(t *testing.T) {
m := chatModel{input: []rune("/think")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("think command should not return a command")
}
m = updated.(chatModel)
if m.thinkPicker == nil {
t.Fatal("think picker should open")
}
if view := stripANSI(m.renderThinkPicker(80)); !strings.Contains(view, "Thinking mode") || !strings.Contains(view, "high") {
t.Fatalf("think picker view missing options: %q", view)
}
}
func TestChatThinkPickerSelectsMode(t *testing.T) {
m := chatModel{}
updated, _ := m.openThinkPicker()
m = updated.(chatModel)
for range 5 {
updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown})
m = updated.(chatModel)
}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
t.Fatal("think picker selection should not return a command")
}
m = updated.(chatModel)
if m.thinkPicker != nil {
t.Fatal("think picker should close after selection")
}
if m.opts.Think == nil || m.opts.Think.Value != "high" {
t.Fatalf("think = %#v, want high", m.opts.Think)
}
if m.status != "think high" {
t.Fatalf("status = %q, want think high", m.status)
}
}
func TestChatThinkCommandSetsModes(t *testing.T) {
tests := []struct {
input string
want any
}{
{input: "/think auto", want: nil},
{input: "/think on", want: true},
{input: "/think off", want: false},
{input: "/think low", want: "low"},
{input: "/think medium", want: "medium"},
{input: "/think high", want: "high"},
{input: "/think max", want: "max"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
m := chatModel{input: []rune(tt.input)}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("think command should not return a command")
}
m = updated.(chatModel)
if tt.want == nil {
if m.opts.Think != nil {
t.Fatalf("think = %#v, want nil", m.opts.Think)
}
return
}
if m.opts.Think == nil || m.opts.Think.Value != tt.want {
t.Fatalf("think = %#v, want %v", m.opts.Think, tt.want)
}
})
}
}
func TestChatLegacySetThinkCommandsStaySupported(t *testing.T) {
tests := []struct {
input string
want any
}{
{input: "/set think", want: true},
{input: "/set think high", want: "high"},
{input: "/set nothink", want: false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
m := chatModel{input: []rune(tt.input)}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy think command should not return a command")
}
m = updated.(chatModel)
if m.opts.Think == nil || m.opts.Think.Value != tt.want {
t.Fatalf("think = %#v, want %v", m.opts.Think, tt.want)
}
})
}
}
func TestChatThinkCommandRejectsInvalidMode(t *testing.T) {
m := chatModel{input: []rune("/think turbo")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("think command should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "Usage: /think") {
t.Fatalf("entries = %#v, want usage error", m.entries)
}
}
func TestChatVerboseCommandTogglesMetrics(t *testing.T) {
m := chatModel{input: []rune("/verbose")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("verbose command should not return a command")
}
m = updated.(chatModel)
if !m.opts.Verbose {
t.Fatal("verbose should be enabled")
}
if m.status != "verbose on" {
t.Fatalf("status = %q, want verbose on", m.status)
}
m.input = []rune("/verbose")
updated, cmd = m.handleSubmit()
if cmd != nil {
t.Fatal("verbose command should not return a command")
}
m = updated.(chatModel)
if m.opts.Verbose {
t.Fatal("verbose should be disabled")
}
if m.status != "verbose off" {
t.Fatalf("status = %q, want verbose off", m.status)
}
}
func TestChatVerboseCommandAcceptsExplicitState(t *testing.T) {
m := chatModel{input: []rune("/verbose on")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("verbose command should not return a command")
}
m = updated.(chatModel)
if !m.opts.Verbose {
t.Fatal("verbose should be enabled")
}
m.input = []rune("/verbose off")
updated, cmd = m.handleSubmit()
if cmd != nil {
t.Fatal("verbose command should not return a command")
}
m = updated.(chatModel)
if m.opts.Verbose {
t.Fatal("verbose should be disabled")
}
}
func TestChatVerboseCommandRejectsInvalidState(t *testing.T) {
m := chatModel{input: []rune("/verbose maybe")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("verbose command should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "Usage: /verbose") {
t.Fatalf("entries = %#v, want usage error", m.entries)
}
}
func TestChatLegacySetVerboseQuietCommandsStaySupported(t *testing.T) {
m := chatModel{input: []rune("/set verbose")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy set verbose should not return a command")
}
m = updated.(chatModel)
if !m.opts.Verbose {
t.Fatal("verbose should be enabled")
}
m.input = []rune("/set quiet")
updated, cmd = m.handleSubmit()
if cmd != nil {
t.Fatal("legacy set quiet should not return a command")
}
m = updated.(chatModel)
if m.opts.Verbose {
t.Fatal("verbose should be disabled")
}
}
func TestChatLegacySetFormatCommandsStaySupported(t *testing.T) {
m := chatModel{input: []rune("/set format json")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy set format should not return a command")
}
m = updated.(chatModel)
if m.opts.Format != "json" {
t.Fatalf("format = %q, want json", m.opts.Format)
}
m.input = []rune("/set noformat")
updated, cmd = m.handleSubmit()
if cmd != nil {
t.Fatal("legacy set noformat should not return a command")
}
m = updated.(chatModel)
if m.opts.Format != "" {
t.Fatalf("format = %q, want empty", m.opts.Format)
}
}
func TestChatLegacySetParameterCommandStaySupported(t *testing.T) {
m := chatModel{input: []rune("/set parameter temperature 0.2")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy set parameter should not return a command")
}
m = updated.(chatModel)
got, ok := m.opts.Options["temperature"].(float32)
if !ok || got != 0.2 {
t.Fatalf("temperature = %#v, want float32(0.2)", m.opts.Options["temperature"])
}
}
func TestChatLegacySetHistoryStaysUnsupported(t *testing.T) {
m := chatModel{input: []rune("/set history")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy set history should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "error" || !strings.Contains(m.entries[0].content, "Unknown command `/set history`") {
t.Fatalf("entries = %#v, want unsupported history error", m.entries)
}
}
func TestChatLegacyLoadCommandSwitchesModel(t *testing.T) {
var savedModel string
m := chatModel{
ctx: context.Background(),
input: []rune("/load qwen3"),
opts: Options{
Model: "llama3.2",
OnModelSelected: func(_ context.Context, model string) error {
savedModel = model
return nil
},
},
}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy load should not return a command")
}
m = updated.(chatModel)
if m.opts.Model != "qwen3" || savedModel != "qwen3" {
t.Fatalf("model = %q saved = %q, want qwen3", m.opts.Model, savedModel)
}
}
func TestChatLegacyShowCommandRendersModelInfo(t *testing.T) {
client := &chatShowTestClient{resp: &api.ShowResponse{
License: "MIT",
Modelfile: "FROM llama3.2",
Parameters: "temperature 0.7",
System: "model system",
Template: "{{ .Prompt }}",
Details: api.ModelDetails{
Family: "llama",
ParameterSize: "3B",
QuantizationLevel: "Q4_K_M",
ContextLength: 8192,
},
}}
m := chatModel{
ctx: context.Background(),
input: []rune("/show info"),
opts: Options{
Model: "llama3.2",
Client: client,
Options: map[string]any{"top_k": int64(10)},
},
}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy show should not return a command")
}
m = updated.(chatModel)
if client.req == nil || client.req.Model != "llama3.2" || client.req.Options["top_k"] != int64(10) {
t.Fatalf("show request = %#v", client.req)
}
if len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "Model info") || !strings.Contains(m.entries[0].content, "context length") {
t.Fatalf("show info entry = %#v", m.entries)
}
m.input = []rune("/show parameters")
updated, cmd = m.handleSubmit()
if cmd != nil {
t.Fatal("legacy show parameters should not return a command")
}
m = updated.(chatModel)
if !strings.Contains(m.entries[len(m.entries)-1].content, "temperature 0.7") ||
!strings.Contains(m.entries[len(m.entries)-1].content, "`top_k`: `10`") {
t.Fatalf("show parameters entry = %q", m.entries[len(m.entries)-1].content)
}
}
func TestChatLegacyHelpCommandsStaySupported(t *testing.T) {
m := chatModel{input: []rune("/help set")}
updated, cmd := m.handleSubmit()
if cmd != nil {
t.Fatal("legacy help should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "Legacy set commands") {
t.Fatalf("help set entry = %#v", m.entries)
}
m.input = []rune("/? show")
updated, cmd = m.handleSubmit()
if cmd != nil {
t.Fatal("legacy show help should not return a command")
}
m = updated.(chatModel)
if !strings.Contains(m.entries[len(m.entries)-1].content, "Legacy show commands") {
t.Fatalf("help show entry = %#v", m.entries[len(m.entries)-1])
}
}
func TestChatSkillSlashCompletionAndTrigger(t *testing.T) {
dir := t.TempDir()
skillDir := filepath.Join(dir, "go-code")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, agentskills.SkillFile), []byte("---\nname: go-code\ndescription: Write idiomatic Go code.\n---\n\n# Go Code\n"), 0o644); err != nil {
t.Fatal(err)
}
catalog, err := agentskills.Load(dir)
if err != nil {
t.Fatal(err)
}
m := chatModel{
ctx: context.Background(),
input: []rune("/go"),
opts: Options{
Model: "test",
Client: &chatCaptureClient{},
Skills: catalog,
},
}
lines := stripANSI(strings.Join(m.slashCommandLines(80), "\n"))
if !strings.Contains(lines, "/go-code") || !strings.Contains(lines, "Write idiomatic Go code") {
t.Fatalf("skill completion missing: %q", lines)
}
m.input = []rune("/go-code write a test")
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd == nil {
t.Fatal("skill trigger should start a run")
}
if len(m.entries) == 0 || m.entries[0].content != "/go-code write a test" {
t.Fatalf("displayed entries = %#v", m.entries)
}
if len(m.promptHistory) != 1 || m.promptHistory[0] != "/go-code write a test" {
t.Fatalf("prompt history = %#v, want slash skill command preserved", m.promptHistory)
}
runDone := waitForRunDone(t, m.events)
if runDone.err != nil {
t.Fatal(runDone.err)
}
client := m.opts.Client.(*chatCaptureClient)
if len(client.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(client.requests))
}
reqMessages := client.requests[0].Messages
if len(reqMessages) < 3 {
t.Fatalf("request messages = %#v", reqMessages)
}
if reqMessages[0].Role == "system" {
if strings.Contains(reqMessages[0].Content, "# Go Code") {
t.Fatalf("system prompt should contain skill metadata only: %#v", reqMessages[0])
}
reqMessages = reqMessages[1:]
}
if got := reqMessages[0].Content; got != "write a test" {
t.Fatalf("user prompt = %q", got)
}
if reqMessages[1].Role != "assistant" || len(reqMessages[1].ToolCalls) != 1 || reqMessages[1].ToolCalls[0].Function.Name != "skill" {
t.Fatalf("manual skill should be a synthetic assistant tool call: %#v", reqMessages[1])
}
if reqMessages[2].Role != "tool" || reqMessages[2].ToolName != "skill" || !strings.Contains(reqMessages[2].Content, "# Go Code") {
t.Fatalf("manual skill should include skill tool result: %#v", reqMessages[2])
}
}
func TestChatSkillsImportCommand(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
src := filepath.Join(home, ".claude", "skills", "go-code")
if err := os.MkdirAll(src, 0o755); err != nil {
t.Fatal(err)
}
content := "---\nname: go-code\ndescription: Write Go code.\n---\n\n# Go Code\n"
if err := os.WriteFile(filepath.Join(src, agentskills.SkillFile), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
registry := coreagent.NewRegistry()
catalog := &agentskills.Catalog{}
m := chatModel{
input: []rune("/skills import claude"),
opts: Options{
Tools: registry,
Skills: catalog,
SystemPromptForModel: func(context.Context, string, *coreagent.Registry) string {
return "default Ollama prompt\n\n" + catalog.SystemPrompt(registry.Has("skill"))
},
},
}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("skills import should not start a model run")
}
if len(m.entries) != 1 || !strings.Contains(m.entries[0].content, "imported go-code") {
t.Fatalf("entries = %#v", m.entries)
}
if m.opts.Skills == nil || !m.opts.Tools.Has("skill") {
t.Fatalf("skills/tool registry not updated: skills=%#v tools=%v", m.opts.Skills, m.opts.Tools.Names())
}
if !strings.Contains(m.opts.SystemPrompt, "default Ollama prompt") || !strings.Contains(m.opts.SystemPrompt, "go-code: Write Go code.") {
t.Fatalf("system prompt not rebuilt with default prompt and skills: %q", m.opts.SystemPrompt)
}
if _, err := os.Stat(filepath.Join(home, ".ollama", "skills", "go-code", agentskills.SkillFile)); err != nil {
t.Fatal(err)
}
}
func TestChatViewRendersFileMentionSuggestions(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "cmd"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
m := chatModel{
input: []rune("check @"),
width: 80,
height: 16,
opts: Options{WorkingDir: dir},
}
view := stripANSI(m.View())
if !strings.Contains(view, "@cmd/") || !strings.Contains(view, "directory") {
t.Fatalf("view missing directory suggestion: %q", view)
}
if !strings.Contains(view, "@README.md") || !strings.Contains(view, "file") {
t.Fatalf("view missing file suggestion: %q", view)
}
}
func TestChatFileMentionSuggestionsFilterAndComplete(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "runner.go"), []byte("package main"), 0o644); err != nil {
t.Fatal(err)
}
m := chatModel{
input: []rune("read @REA"),
opts: Options{WorkingDir: dir},
}
lines := stripANSI(strings.Join(m.completionLines(80), "\n"))
if !strings.Contains(lines, "@README.md") {
t.Fatalf("suggestions missing README.md: %q", lines)
}
if strings.Contains(lines, "@runner.go") {
t.Fatalf("suggestions should filter out runner.go: %q", lines)
}
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyTab})
m = updated.(chatModel)
if got := string(m.input); got != "read @README.md " {
t.Fatalf("input = %q, want completed file mention", got)
}
}