package chat import ( "context" "fmt" "os" "path/filepath" "strings" "testing" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" coreagent "github.com/ollama/ollama/agent" "github.com/ollama/ollama/api" ) func TestChatAssistantEntryHasNoLabel(t *testing.T) { m := chatModel{} prefix, _ := m.renderEntry(chatEntry{role: "assistant", content: "hello"}) if strings.Contains(prefix, "Ollama:") { t.Fatalf("prefix should not include Ollama label: %q", prefix) } if prefix != "" { t.Fatalf("prefix = %q, want empty", prefix) } } func TestChatViewRendersEmptyPromptHint(t *testing.T) { m := chatModel{ chatID: "chat-a", width: 80, height: 12, } view := stripANSI(m.View()) lines := strings.Split(view, "\n") hintLine := lineIndexContaining(lines, "what changed on this branch?") if hintLine < 0 { t.Fatalf("empty chat view missing prompt hint: %q", view) } if strings.Contains(view, "Start a conversation. Use /help for commands.") { t.Fatalf("empty chat view should use rotating prompt hint: %q", view) } } func TestChatUserEntryHasNoLabel(t *testing.T) { m := chatModel{entries: []chatEntry{{role: "user", content: "hello"}}} prefix, body := m.renderEntry(m.entries[0]) if prefix != "" { t.Fatalf("prefix = %q, want empty", prefix) } if body != "hello" { t.Fatalf("body = %q, want hello", body) } transcript := stripANSI(m.renderTranscript(80)) if !strings.Contains(transcript, " hello") { t.Fatalf("user transcript should render as user block: %q", transcript) } } func TestChatSystemEntryHasNoLabel(t *testing.T) { m := chatModel{entries: []chatEntry{{role: "system", content: "Available tools"}}} prefix, body := m.renderEntry(m.entries[0]) if prefix != "" { t.Fatalf("prefix = %q, want empty", prefix) } if body != "Available tools" { t.Fatalf("body = %q, want Available tools", body) } if transcript := stripANSI(m.renderTranscript(80)); strings.Contains(transcript, "sys ") { t.Fatalf("system transcript should not render sys prefix: %q", transcript) } } func TestChatMixedToolGroupUsesMixedPrefix(t *testing.T) { m := chatModel{} entry := newChatEntry(chatEntry{ role: "tool_group", label: "Tool calls (2)", status: "error", tools: []chatEntry{ newChatEntry(chatEntry{role: "tool", status: "done"}), newChatEntry(chatEntry{role: "tool", status: "error", err: "failed"}), }, }) prefix, body := m.renderEntry(entry) if prefix != chatToolMixedStyle.Render("•")+" " { t.Fatalf("prefix = %q, want mixed-styled bullet", prefix) } body = stripANSI(body) if strings.Contains(body, "failed") || strings.Contains(body, "succeeded") || strings.Contains(body, "done") { t.Fatalf("body = %q, should not show status words for mixed group", body) } } func TestChatToolStatusLineDoesNotUseDisclosureGlyph(t *testing.T) { startedAt := time.Date(2026, 6, 22, 13, 0, 0, 0, time.UTC) entry := newChatEntry(chatEntry{ role: "tool", label: `Web Search("who is parth sareen")`, status: "done", content: "hidden result", startedAt: startedAt, finishedAt: startedAt.Add(812 * time.Millisecond), }) line := stripANSI(toolStatusLine(entry)) if strings.Contains(line, "▸") || strings.Contains(line, "▾") { t.Fatalf("tool status line should not include disclosure glyph: %q", line) } if line != `Web Search("who is parth sareen")` { t.Fatalf("tool status line = %q, want label only", line) } for _, word := range []string{"done", "in 812ms", "812ms"} { if strings.Contains(line, word) { t.Fatalf("tool status line should not include %q: %q", word, line) } } } func TestChatCompletedToolStatusLineUsesResultStyle(t *testing.T) { entry := newChatEntry(chatEntry{ role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done", }) if line := toolStatusLine(entry); line != `Bash("pwd")` { t.Fatalf("tool status line = %q, want command label", line) } prefix, _ := (chatModel{}).renderEntry(entry) if prefix != chatToolDoneStyle.Render("•")+" " { t.Fatalf("tool prefix = %q, want done-styled marker", prefix) } } func TestChatToolStatusMarkerUsesStateColors(t *testing.T) { if got, want := chatToolRunningStyle.GetForeground(), lipgloss.Color(chatAnsiYellow); got != want { t.Fatalf("running tool foreground = %v, want %v", got, want) } if got, want := chatToolDoneStyle.GetForeground(), lipgloss.Color(chatAnsiGreen); got != want { t.Fatalf("done tool foreground = %v, want %v", got, want) } running := newChatEntry(chatEntry{ role: "tool", detail: "bash", label: `Bash("pwd")`, status: "running", }) if line := toolStatusLine(running); line != `Bash("pwd")` { t.Fatalf("running tool status line = %q, want command label", line) } prefix, _ := (chatModel{}).renderEntry(running) if prefix != chatToolRunningStyle.Render("•")+" " { t.Fatalf("running tool prefix = %q, want running-styled marker", prefix) } child := newChatEntry(chatEntry{ role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done", }) if line := toolGroupChildStatusLine(child); line != boldToolInvocationName(`Bash("pwd")`) { t.Fatalf("tool group child line = %q, want neutral invocation", line) } } func TestChatViewRendersInputBox(t *testing.T) { m := chatModel{ input: []rune("hello"), width: 40, height: 12, } view := stripANSI(m.View()) if !strings.Contains(view, inputBoxTopBorderLine(40)) || !strings.Contains(view, inputBoxBottomBorderLine(40)) { t.Fatalf("prompt input should render box borders: %q", view) } if !strings.Contains(view, "│ hello") { t.Fatalf("view missing prompt input row: %q", view) } } func TestChatViewRendersSentUserPromptWithoutPrefix(t *testing.T) { m := chatModel{ width: 40, height: 12, entries: []chatEntry{ {role: "user", content: "hello"}, }, } view := stripANSI(m.View()) if !strings.Contains(view, "hello") { t.Fatalf("submitted user message should render: %q", view) } if strings.Contains(view, "> hello") { t.Fatalf("submitted user message should not include prompt prefix: %q", view) } if strings.Contains(view, "│ >") { t.Fatalf("active input should not include prompt prefix: %q", view) } } func TestChatFlowViewStartsAtInputWhenEmpty(t *testing.T) { m := chatModel{ input: []rune("hello"), width: 40, } lines := strings.Split(stripANSI(m.View()), "\n") if len(lines) == 0 || !strings.Contains(lines[0], inputBoxTopBorderLine(40)) { t.Fatalf("empty flow view should start at input box:\n%s", strings.Join(lines, "\n")) } } func TestFlowTranscriptChangedPrefixStartDetectsToolGrouping(t *testing.T) { firstArgs := map[string]any{"command": "pwd"} secondArgs := map[string]any{"command": "ls"} beforeModel := chatModel{ width: 120, entries: []chatEntry{ { role: "tool", label: `Bash("pwd")`, detail: "bash", status: "done", toolID: "call-1", args: firstArgs, }, }, } before := beforeModel.transcriptLines(120) afterModel := beforeModel afterModel.entries = groupCompletedToolEntries([]chatEntry{ beforeModel.entries[0], { role: "tool", label: `Bash("ls")`, detail: "bash", status: "done", toolID: "call-2", args: secondArgs, }, }) after := afterModel.transcriptLines(120) if start := flowTranscriptChangedPrefixStart(before, after, len(before)); start != 0 { t.Fatalf("changed prefix start = %d, want 0; before=%q after=%q", start, before, after) } sequence := flowTranscriptRewriteSequence(len(before), after) if !strings.HasPrefix(sequence, "\x1b[1A\r\x1b[J") { t.Fatalf("rewrite sequence = %q, want cursor-up clear-below prefix", sequence) } if !strings.Contains(sequence, "Ran 2 commands") { t.Fatalf("grouping should invalidate already printed tool row; before=%q after=%q", before, afterModel.transcriptLines(120)) } } func TestFlowTranscriptChangedPrefixStartIgnoresAppendedLines(t *testing.T) { beforeModel := chatModel{ width: 120, entries: []chatEntry{ {role: "assistant", content: "hello"}, }, } before := beforeModel.transcriptLines(120) afterModel := beforeModel afterModel.entries = append(afterModel.entries, chatEntry{role: "assistant", content: "world"}) if start := flowTranscriptChangedPrefixStart(before, afterModel.transcriptLines(120), len(before)); start >= 0 { t.Fatalf("appended transcript lines should not invalidate already printed prefix") } } func TestChatViewRendersCursorWithEmptyPlaceholder(t *testing.T) { m := chatModel{ chatID: "placeholder-chat", width: 80, height: 12, } view := stripANSI(m.View()) if !strings.Contains(view, "summarize this file and suggest edits") { t.Fatalf("empty placeholder should show hint: %q", view) } } func TestChatViewRendersModelUnderInputBox(t *testing.T) { m := chatModel{ input: []rune("hello"), width: 48, height: 12, opts: Options{ Model: "kimi-k2.7-code:cloud", }, } lines := strings.Split(stripANSI(m.View()), "\n") inputLine := lineIndexContaining(lines, "│ hello") modelLine := lineIndexContaining(lines, "kimi-k2.7-code:cloud") if inputLine < 0 || modelLine < 0 { t.Fatalf("view missing input or model line:\n%s", strings.Join(lines, "\n")) } if modelLine != inputLine+2 { t.Fatalf("model line should sit directly under input: input=%d model=%d\n%s", inputLine, modelLine, strings.Join(lines, "\n")) } if strings.Contains(lines[modelLine], "model ") { t.Fatalf("model line should not include a label:\n%s", strings.Join(lines, "\n")) } } func TestChatViewExpandsInputBoxForLongPrompt(t *testing.T) { m := chatModel{ input: []rune(strings.Repeat("long prompt ", 8)), width: 32, height: 14, } view := stripANSI(m.View()) if got := inputPromptLineCount(t, view); got < 2 { t.Fatalf("input body lines = %d, want wrapped prompt:\n%s", got, view) } if !strings.Contains(view, "█") { t.Fatalf("view missing cursor: %q", view) } } func TestChatViewCapsTallInputBox(t *testing.T) { m := chatModel{ input: []rune(strings.Repeat("pasted text ", 80)), width: 32, height: 12, } view := stripANSI(m.View()) if got := inputPromptLineCount(t, view); got > maxInputBoxBodyLines { t.Fatalf("input body lines = %d, want <= %d:\n%s", got, maxInputBoxBodyLines, view) } if strings.Contains(view, "... ... ") { t.Fatalf("truncated pasted prompt should not duplicate omission markers:\n%s", view) } } func TestChatViewWrapsNotificationWhenNarrow(t *testing.T) { m := chatModel{ input: []rune("hello"), width: 28, height: 14, status: "cache will break by turning system prompt off", approvalState: testApprovalState(true, nil), opts: Options{ ContextWindowTokens: 262144, }, contextTokens: 12345, contextEstimate: true, } view := stripANSI(m.View()) for _, want := range []string{ "cache will break by", "system prompt off", } { if !strings.Contains(view, want) { t.Fatalf("wrapped notification missing %q:\n%s", want, view) } } for _, hidden := range []string{"enter", "send", "/model", "full", "access"} { if strings.Contains(view, hidden) { t.Fatalf("view should not render footer chrome %q:\n%s", hidden, view) } } if strings.Contains(view, "ctx") { t.Fatalf("view should hide distant context pressure:\n%s", view) } if strings.Contains(view, "ctrl+g") { t.Fatalf("view should not include ctrl+g hint:\n%s", view) } for _, line := range strings.Split(view, "\n") { if len([]rune(line)) > 28 { t.Fatalf("line width = %d, want <= 28: %q\n%s", len([]rune(line)), line, view) } } } func TestPromptTokenTextDoesNotUseCompactionFallback(t *testing.T) { m := chatModel{} if got := m.promptTokenText(702); got != "702 tokens" { t.Fatalf("promptTokenText without model context = %q, want bare token count", got) } m.opts.ContextWindowTokens = 262144 if got := m.promptTokenText(702); got != "702 / 256k tokens" { t.Fatalf("promptTokenText with model context = %q", got) } m.opts.Options = map[string]any{"num_ctx": 131072} if got := m.promptTokenText(702); got != "702 / 128k tokens" { t.Fatalf("promptTokenText with num_ctx = %q", got) } m.opts.Options = map[string]any{"num_ctx": 1000000} if got := m.promptTokenText(702); got != "702 / 1M tokens" { t.Fatalf("promptTokenText with large num_ctx = %q", got) } m.opts.Options = map[string]any{"num_ctx": 99999} if got := m.promptTokenText(702); got != "702 / 99999 tokens" { t.Fatalf("promptTokenText below compact threshold = %q", got) } } func TestPreloadDoneUpdatesContextWindowTokens(t *testing.T) { compactor := &coreagent.SimpleCompactor{} m := chatModel{ preloadingModel: "ornith", opts: Options{ ContextWindowTokens: 32768, Compactor: compactor, }, } next, _ := m.Update(chatModelPreloadDoneMsg{model: "ornith", contextWindowTokens: 262144}) got := next.(chatModel) if got.opts.ContextWindowTokens != 262144 { t.Fatalf("context window = %d, want 262144", got.opts.ContextWindowTokens) } if compactor.Options.ContextWindowTokens != 262144 { t.Fatalf("compactor context window = %d, want 262144", compactor.Options.ContextWindowTokens) } } func TestChatViewRendersNotificationAboveInput(t *testing.T) { m := chatModel{ input: []rune("hello"), width: 40, height: 12, status: "copied latest output", } view := stripANSI(m.View()) lines := strings.Split(view, "\n") borderLine := lineIndexContaining(lines, inputBoxTopBorderLine(40)) if borderLine < 0 { t.Fatalf("view missing input box:\n%s", view) } if borderLine < 1 || !strings.Contains(lines[borderLine-1], "copied latest output") { t.Fatalf("notification should sit directly above input box:\n%s", view) } } func TestChatNotificationsUseSecondaryStyle(t *testing.T) { if !chatNotificationStyle.GetFaint() { t.Fatal("notification style should use secondary/faint styling") } } func TestChatSubmittedPromptUsesThemeSecondaryGrey(t *testing.T) { if got, want := chatUserBlockStyle.GetForeground(), lipgloss.TerminalColor(lipgloss.AdaptiveColor{Light: "#777777", Dark: "#8a8a8a"}); got != want { t.Fatalf("submitted prompt foreground = %v, want %v", got, want) } if chatUserBlockStyle.GetFaint() { t.Fatal("submitted prompt should use secondary grey, not faint styling") } } func TestChatToolOutputUsesDistinctSecondaryGrey(t *testing.T) { if got, want := chatToolOutputStyle.GetForeground(), lipgloss.TerminalColor(lipgloss.AdaptiveColor{Light: "#666666", Dark: "#a0a0a0"}); got != want { t.Fatalf("tool output foreground = %v, want %v", got, want) } } func TestChatInlineCodeDoesNotLookSelected(t *testing.T) { if chatInlineCodeStyle.GetReverse() { t.Fatal("inline code should not use reverse-video styling") } if !chatInlineCodeStyle.GetBold() { t.Fatal("inline code should still have lightweight emphasis") } } func inputPromptLineCount(t *testing.T, view string) int { t.Helper() count := 0 inInputBox := false for _, line := range strings.Split(view, "\n") { trimmed := strings.TrimSpace(line) if strings.HasPrefix(trimmed, "╭") { inInputBox = true continue } if strings.HasPrefix(trimmed, "╰") { inInputBox = false continue } if inInputBox && strings.Contains(trimmed, "│") { count++ } } if count == 0 { t.Fatalf("input prompt lines not found:\n%s", view) } return count } func TestChatViewKeepsInputBoxWhileRunning(t *testing.T) { m := chatModel{ input: []rune("next"), running: true, thinking: true, thinkingTokens: 42, width: 40, height: 12, } view := stripANSI(m.View()) if !strings.Contains(view, "│ next") { t.Fatalf("running view should keep input row: %q", view) } if strings.Contains(view, "↑/↓ scroll") || strings.Contains(view, "/new chat") || strings.Contains(view, "/clear reset") { t.Fatalf("footer should not include scroll/new/clear hints: %q", view) } lines := strings.Split(view, "\n") borderLine := lineIndexContaining(lines, inputBoxTopBorderLine(40)) if borderLine < 0 { t.Fatalf("view missing input box: %q", view) } if borderLine < 1 || !strings.Contains(lines[borderLine-1], "Thinking ↓ 42 tokens") { t.Fatalf("thinking line should sit directly above input box:\n%s", view) } if strings.Contains(lines[borderLine-1], "...") { t.Fatalf("thinking line should not show spinner dots:\n%s", view) } } func TestChatToolFinishedUpdatesLiveWorkingDirOnly(t *testing.T) { root := t.TempDir() subdir := filepath.Join(root, "sub") if err := os.Mkdir(subdir, 0o755); err != nil { t.Fatal(err) } m := chatModel{ width: 140, height: 12, opts: Options{ RootDir: root, WorkingDir: root, }, workingDir: root, } m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolFinished, ToolCallID: "call-1", ToolName: "bash", WorkingDir: subdir, }) if m.workingDir != subdir { t.Fatalf("workingDir = %q, want %q", m.workingDir, subdir) } if m.opts.WorkingDir != root { t.Fatalf("opts.WorkingDir mutated to %q, want %q", m.opts.WorkingDir, root) } } func TestChatBoundedFramePinsInputAfterResize(t *testing.T) { m := chatModel{ input: []rune("next"), width: 60, height: 14, } for i := range 20 { m.entries = append(m.entries, chatEntry{role: "assistant", content: fmt.Sprintf("line %02d", i)}) } inputLine := renderedInputLine(m.View()) if inputLine < m.height-4 { t.Fatalf("bounded frame should pin input near bottom, input line=%d height=%d\n%s", inputLine, m.height, stripANSI(m.View())) } } func TestChatViewSeparatesActionStatusFromTranscript(t *testing.T) { m := chatModel{ input: []rune("next"), width: 72, height: 14, running: true, thinking: true, entries: []chatEntry{ {role: "assistant", content: "Working through it."}, }, } lines := strings.Split(stripANSI(m.View()), "\n") assistantLine := lineIndexContaining(lines, "Working through it.") activityLine := lineIndexContaining(lines, "Thinking") if assistantLine < 0 || activityLine < 0 { t.Fatalf("view missing assistant/activity lines:\n%s", strings.Join(lines, "\n")) } if gap := activityLine - assistantLine - 1; gap < 1 { t.Fatalf("gap between transcript and action status = %d, want at least 1:\n%s", gap, strings.Join(lines, "\n")) } } func TestChatViewDoesNotReserveIdleActionSpacerAfterResponse(t *testing.T) { m := chatModel{ input: []rune("next"), width: 72, height: 12, entries: []chatEntry{ {role: "user", content: "hi"}, {role: "assistant", content: "Hello."}, }, } lines := strings.Split(stripANSI(m.View()), "\n") assistantLine := lineIndexContaining(lines, "Hello.") inputLine := lineIndexContaining(lines, "│ next") if assistantLine < 0 || inputLine < 0 { t.Fatalf("view missing assistant/input lines:\n%s", strings.Join(lines, "\n")) } if gap := inputLine - assistantLine - 1; gap < 2 { t.Fatalf("gap between finished response and input body = %d, want at least 2:\n%s", gap, strings.Join(lines, "\n")) } } func TestChatViewHidesEmptyHintWhileTyping(t *testing.T) { m := chatModel{ input: []rune("next"), width: 72, height: 12, } lines := strings.Split(stripANSI(m.View()), "\n") hintLine := lineIndexContaining(lines, "Try:") inputLine := lineIndexContaining(lines, "│ next") if hintLine >= 0 { t.Fatalf("view should not show empty hint while typing:\n%s", strings.Join(lines, "\n")) } if inputLine < 0 { t.Fatalf("view missing input line:\n%s", strings.Join(lines, "\n")) } } func renderedInputLine(view string) int { for i, line := range strings.Split(stripANSI(view), "\n") { if strings.Contains(line, "│ next") { return i } } return -1 } func lineIndexContaining(lines []string, needle string) int { for i, line := range lines { if strings.Contains(line, needle) { return i } } return -1 } func TestChatScrollsTranscript(t *testing.T) { m := chatModel{ width: 80, height: 10, } for range 12 { m.entries = append(m.entries, chatEntry{role: "user", content: "line"}) } if m.maxScroll() == 0 { t.Fatal("test setup should produce scrollable transcript") } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) m = updated.(chatModel) if m.scroll == 0 { t.Fatal("page up should scroll transcript") } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) m = updated.(chatModel) if m.scroll != 0 { t.Fatalf("scroll = %d, want 0", m.scroll) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlHome}) m = updated.(chatModel) if m.scroll != m.maxScroll() { t.Fatalf("scroll = %d, want max %d", m.scroll, m.maxScroll()) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlEnd}) m = updated.(chatModel) if m.scroll != 0 { t.Fatalf("scroll = %d, want 0", m.scroll) } } func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) { m := chatModel{ width: 80, height: 12, running: true, events: make(chan tea.Msg), } updated, _ := m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "generated line 00"}}) m = updated.(chatModel) if !strings.Contains(stripANSI(m.View()), "generated line 00") { t.Fatalf("live output should be visible in flow view:\n%s", stripANSI(m.View())) } updated, _ = m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "\n\ngenerated line 01"}}) m = updated.(chatModel) view := stripANSI(m.View()) if !strings.Contains(view, "generated line 00") { t.Fatalf("live assistant output should remain visible while streaming:\n%s", view) } if !strings.Contains(view, "generated line 01") { t.Fatalf("latest generated line should remain visible:\n%s", view) } } func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) { m := chatModel{ width: 80, height: 10, running: true, input: []rune("current draft"), promptHistory: []string{"previous one", "previous two"}, } for range 12 { m.entries = append(m.entries, chatEntry{role: "user", content: "line"}) } if m.maxScroll() == 0 { t.Fatal("test setup should produce scrollable transcript") } updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseWheelUp}) m = updated.(chatModel) if m.scroll == 0 { t.Fatal("mouse wheel up should scroll transcript while running") } if got := string(m.input); got != "current draft" { t.Fatalf("mouse wheel should not navigate prompt history, input = %q", got) } updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseWheelDown}) m = updated.(chatModel) if m.scroll != 0 { t.Fatalf("mouse wheel down should return to bottom, got scroll %d", m.scroll) } if got := string(m.input); got != "current draft" { t.Fatalf("mouse wheel should leave draft alone, input = %q", got) } } func TestChatWindowsMouseWheelScrollsTranscript(t *testing.T) { oldGOOS := chatRuntimeGOOS chatRuntimeGOOS = "windows" defer func() { chatRuntimeGOOS = oldGOOS }() m := chatModel{ width: 80, height: 8, } for i := range 20 { m.entries = append(m.entries, chatEntry{role: "user", content: fmt.Sprintf("line-%02d", i)}) } updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseWheelUp}) m = updated.(chatModel) if m.scroll == 0 { t.Fatal("windows mouse wheel up should scroll transcript") } updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseWheelDown}) m = updated.(chatModel) if m.scroll != 0 { t.Fatalf("windows mouse wheel down should return to bottom, got scroll %d", m.scroll) } } func TestChatMouseDragSelectsTranscriptWithoutAutoCopy(t *testing.T) { oldGOOS := chatRuntimeGOOS chatRuntimeGOOS = "darwin" defer func() { chatRuntimeGOOS = oldGOOS }() m := chatModel{ width: 80, height: 10, entries: []chatEntry{ {role: "user", content: "alpha beta"}, }, } top, _ := m.transcriptLayout() updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top}) m = updated.(chatModel) updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 7, Y: top}) m = updated.(chatModel) if got := m.selectedTranscriptText(80); got != "alpha" { t.Fatalf("selected text = %q, want alpha", got) } if !m.selection.active { t.Fatal("selection should stay active during drag") } if !m.selection.dragging { t.Fatal("selection should track drag before release") } updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 7, Y: top}) m = updated.(chatModel) if cmd != nil { t.Fatal("mouse release should not auto-copy selected text") } if !m.selection.active { t.Fatal("selection should stay visible on release") } if m.selection.dragging { t.Fatal("selection should stop tracking drag on release") } if got := m.selectedTranscriptText(80); got != "alpha" { t.Fatalf("selected text after release = %q, want alpha", got) } } func TestChatWindowsMouseDragCopiesAndClearsTranscriptSelection(t *testing.T) { oldGOOS := chatRuntimeGOOS chatRuntimeGOOS = "windows" defer func() { chatRuntimeGOOS = oldGOOS }() var copied string oldClipboard := writeClipboard writeClipboard = func(_ context.Context, text string) error { copied = text return nil } defer func() { writeClipboard = oldClipboard }() m := chatModel{ width: 80, height: 10, entries: []chatEntry{ {role: "user", content: "alpha beta"}, }, } top, _ := m.transcriptLayout() updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top}) m = updated.(chatModel) updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 7, Y: top}) m = updated.(chatModel) if got := m.selectedTranscriptText(80); got != "alpha" { t.Fatalf("selected text = %q, want alpha", got) } updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 7, Y: top}) m = updated.(chatModel) if cmd == nil { t.Fatal("mouse release should copy selected text on windows") } if msg := cmd(); msg != nil { t.Fatalf("clipboard command message = %#v, want nil", msg) } if copied != "alpha" { t.Fatalf("copied text = %q, want alpha", copied) } if m.selection.active { t.Fatal("windows selection should clear after copy") } if m.status != "copied" { t.Fatalf("status = %q, want copied", m.status) } } func TestChatMouseDragSelectionUsesDisplayColumns(t *testing.T) { m := chatModel{ width: 80, height: 10, entries: []chatEntry{ {role: "user", content: "a界b"}, }, } top, _ := m.transcriptLayout() contentX := 2 updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: contentX, Y: top}) m = updated.(chatModel) updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: contentX + 3, Y: top}) m = updated.(chatModel) if got := m.selectedTranscriptText(80); got != "a界" { t.Fatalf("selected text = %q, want a界", got) } } func TestChatBoundedViewDoesNotRenderScrollHeader(t *testing.T) { m := chatModel{ width: 80, height: 6, } for i := range 12 { m.entries = append(m.entries, chatEntry{role: "user", content: fmt.Sprintf("line-%02d", i)}) } m.scroll = m.maxScroll() top, _ := m.transcriptLayout() lines := strings.Split(stripANSI(m.View()), "\n") if top != 0 { t.Fatalf("transcript top = %d, want no status header", top) } if strings.Contains(lines[0], "more") { t.Fatalf("view should not render scroll status header: %q", lines[0]) } if strings.TrimSpace(lines[top]) == "" { t.Fatalf("transcript should start at layout top %d, line=%q view=%q", top, lines[top], strings.Join(lines, "\n")) } } func TestChatMouseDragSelectionUsesScrolledTranscriptCoordinates(t *testing.T) { oldGOOS := chatRuntimeGOOS chatRuntimeGOOS = "darwin" defer func() { chatRuntimeGOOS = oldGOOS }() m := chatModel{ width: 80, height: 8, } for i := range 10 { m.entries = append(m.entries, chatEntry{role: "user", content: fmt.Sprintf("line-%02d", i)}) } m.scroll = m.maxScroll() top, _ := m.transcriptLayout() updated, _ := m.Update(tea.MouseMsg{Type: tea.MouseLeft, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress, X: 2, Y: top}) m = updated.(chatModel) updated, _ = m.Update(tea.MouseMsg{Type: tea.MouseMotion, Button: tea.MouseButtonLeft, Action: tea.MouseActionMotion, X: 9, Y: top}) m = updated.(chatModel) if got := m.selectedTranscriptText(80); got != "line-00" { t.Fatalf("selected text = %q, want line-00", got) } updated, cmd := m.Update(tea.MouseMsg{Type: tea.MouseRelease, Action: tea.MouseActionRelease, X: 9, Y: top}) m = updated.(chatModel) if cmd != nil { t.Fatal("mouse release should not auto-copy selected text") } if !m.selection.active { t.Fatal("selection should stay visible on release") } if m.selection.dragging { t.Fatal("selection should stop tracking drag on release") } } func TestChatArrowKeysNavigatePromptHistoryWhenTranscriptScrollable(t *testing.T) { m := chatModel{ input: []rune("current draft"), promptHistory: []string{"old prompt"}, width: 80, height: 10, running: true, } for range 12 { m.entries = append(m.entries, chatEntry{role: "user", content: "line"}) } if m.maxScroll() == 0 { t.Fatal("test setup should produce scrollable transcript") } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyUp}) m = updated.(chatModel) if m.scroll != 0 { t.Fatalf("key up should not scroll transcript, got %d", m.scroll) } if got := string(m.input); got != "old prompt" { t.Fatalf("key up should recall prompt history, got %q", got) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) m = updated.(chatModel) if m.scroll != 0 { t.Fatalf("key down should not scroll transcript, got %d", m.scroll) } if got := string(m.input); got != "current draft" { t.Fatalf("key down should restore draft, got %q", got) } } func TestEntriesFromMessagesSkipsToolCallOnlyAssistant(t *testing.T) { args := api.NewToolCallFunctionArguments() args.Set("command", "pwd") messages := []api.Message{ {Role: "user", Content: "pwd"}, { Role: "assistant", ToolCalls: []api.ToolCall{{ ID: "call-1", Function: api.ToolCallFunction{ Name: "bash", Arguments: args, }, }}, }, {Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: "/tmp/project\n"}, {Role: "assistant", Content: "The current directory is /tmp/project."}, } entries := entriesFromMessages(messages) if len(entries) != 3 { t.Fatalf("entries = %d, want user/tool/assistant: %#v", len(entries), entries) } if entries[1].role != "tool" || entries[1].label != "Bash(\"pwd\")" { t.Fatalf("tool entry = %#v", entries[1]) } if line := stripANSI(toolStatusLine(entries[1])); line != `Bash("pwd")` { t.Fatalf("tool status line = %q, want command label", line) } transcript := stripANSI((chatModel{entries: entries}).renderTranscript(120)) if strings.Contains(transcript, "• \n") || strings.Contains(transcript, "•\n") { t.Fatalf("transcript has blank assistant bullet: %q", transcript) } } func TestToolActionPhraseLoadsSkills(t *testing.T) { if got := toolActionPhrase("skill", 1); got != "Loaded a skill" { t.Fatalf("single skill action = %q", got) } if got := toolActionPhrase("skill", 2); got != "Loaded 2 skills" { t.Fatalf("multiple skill action = %q", got) } } func TestEntriesFromMessagesRendersDeniedCommandAsDenied(t *testing.T) { args := api.NewToolCallFunctionArguments() args.Set("command", "pwd") messages := []api.Message{ { Role: "assistant", ToolCalls: []api.ToolCall{{ ID: "call-1", Function: api.ToolCallFunction{ Name: "bash", Arguments: args, }, }}, }, {Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: "Tool execution denied."}, } entries := entriesFromMessages(messages) if len(entries) != 1 { t.Fatalf("entries = %d, want one tool entry: %#v", len(entries), entries) } if entries[0].status != "denied" { t.Fatalf("tool status = %q, want denied: %#v", entries[0].status, entries[0]) } if line := stripANSI(toolStatusLine(entries[0])); line != `Bash("pwd") denied` { t.Fatalf("tool status line = %q, want denied command label", line) } } func TestEntriesFromMessagesRendersDisabledToolAsSkipped(t *testing.T) { args := api.NewToolCallFunctionArguments() args.Set("command", "pwd") messages := []api.Message{ { Role: "assistant", ToolCalls: []api.ToolCall{{ ID: "call-1", Function: api.ToolCallFunction{ Name: "bash", Arguments: args, }, }}, }, {Role: "tool", ToolName: "bash", ToolCallID: "call-1", Content: "Tool execution disabled."}, } entries := entriesFromMessages(messages) if len(entries) != 1 { t.Fatalf("entries = %d, want one tool entry: %#v", len(entries), entries) } if entries[0].status != "disabled" { t.Fatalf("tool status = %q, want disabled: %#v", entries[0].status, entries[0]) } if action := toolActionForEntry(entries[0]); action != "disabled_command" { t.Fatalf("tool action = %q, want disabled_command", action) } if phrase := toolActionPhrase(toolActionForEntry(entries[0]), 1); phrase != "Skipped a command" { t.Fatalf("tool action phrase = %q, want skipped command", phrase) } } func TestEntriesFromMessagesRendersCompactionSummaryCollapsed(t *testing.T) { entries := entriesFromMessages([]api.Message{ {Role: "assistant", ToolCalls: []api.ToolCall{{ ID: coreagent.CompactionToolCallID, Function: api.ToolCallFunction{ Name: coreagent.CompactionToolName, }, }}}, {Role: "tool", ToolName: coreagent.CompactionToolName, ToolCallID: coreagent.CompactionToolCallID, Content: coreagent.CompactionSummaryMessagePrefix + "- old work\n- decisions"}, {Role: "user", Content: "recent request"}, }) if len(entries) != 2 { t.Fatalf("entries = %d, want summary plus user: %#v", len(entries), entries) } if entries[0].role != "compaction_summary" || entries[0].content != "- old work\n- decisions" { t.Fatalf("summary entry = %#v", entries[0]) } m := chatModel{entries: entries} transcript := stripANSI(m.renderTranscript(100)) if !strings.Contains(transcript, "Compacted summary") { t.Fatalf("collapsed summary row missing: %q", transcript) } if strings.Contains(transcript, "Compacted summary done") { t.Fatalf("summary row should not include done word: %q", transcript) } if strings.Contains(transcript, "old work") { t.Fatalf("summary body should be collapsed: %q", transcript) } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) view := stripANSI(m.renderTranscript(100)) if !strings.Contains(view, "old work") || !strings.Contains(view, "decisions") { t.Fatalf("inline summary body missing: %q", view) } } func TestEntriesFromMessagesHidesAutomaticCompactionInstruction(t *testing.T) { entries := entriesFromMessages([]api.Message{ { Role: "tool", ToolName: coreagent.CompactionToolName, ToolCallID: coreagent.CompactionToolCallID, Content: coreagent.CompactionSummaryMessagePrefix + "old work summary\n\n" + coreagent.CompactionContinueInstruction, }, }) if len(entries) != 1 || entries[0].role != "compaction_summary" || entries[0].content != "old work summary" { t.Fatalf("entries = %#v", entries) } } func TestEntriesFromMessagesRecognizesLegacySystemCompactionSummary(t *testing.T) { entries := entriesFromMessages([]api.Message{ {Role: "system", Content: "Conversation summary:\nlegacy summary"}, }) if len(entries) != 1 || entries[0].role != "compaction_summary" || entries[0].content != "legacy summary" { t.Fatalf("entries = %#v", entries) } } func TestEntriesFromMessagesGroupsMultiToolHistoryInMiddle(t *testing.T) { readArgs := api.NewToolCallFunctionArguments() readArgs.Set("path", "feedback") listArgs := api.NewToolCallFunctionArguments() listArgs.Set("path", ".") messages := []api.Message{ {Role: "user", Content: "read feedback file"}, { Role: "assistant", ToolCalls: []api.ToolCall{ { ID: "call-read", Function: api.ToolCallFunction{ Name: "read", Arguments: readArgs, }, }, { ID: "call-list", Function: api.ToolCallFunction{ Name: "list", Arguments: listArgs, }, }, }, }, {Role: "tool", ToolName: "read", ToolCallID: "call-read", Content: "Error: no such file"}, {Role: "tool", ToolName: "list", ToolCallID: "call-list", Content: "feedback.md\n"}, {Role: "assistant", Content: "There is a feedback.md file."}, } entries := entriesFromMessages(messages) if len(entries) != 3 { t.Fatalf("entries = %d, want user/tool-group/assistant: %#v", len(entries), entries) } if entries[1].role != "tool_group" || len(entries[1].tools) != 2 { t.Fatalf("middle entry should be grouped tool history: %#v", entries[1]) } if entries[1].tools[0].label != "Read(\"feedback\")" || entries[1].tools[1].label != "List(\".\")" { t.Fatalf("group labels = %#v", entries[1].tools) } } func TestChatToolCallDetectedDoesNotRenderQueuedRows(t *testing.T) { args := api.NewToolCallFunctionArguments() args.Set("command", "pwd") m := chatModel{} m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolCallDetected, ToolCalls: []api.ToolCall{{ ID: "call-1", Function: api.ToolCallFunction{ Name: "bash", Arguments: args, }, }}, }) if len(m.entries) != 0 { t.Fatalf("queued tool call should not create history entries: %#v", m.entries) } m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash", Args: args.ToMap(), }) if len(m.entries) != 1 { t.Fatalf("started tool should create one visible entry, got %d", len(m.entries)) } if got := m.activityLabel(); got != "" { t.Fatalf("activityLabel = %q, want no transient tool label", got) } if line := strings.TrimSpace(stripANSI(m.activityLine())); line != "" { t.Fatalf("activityLine = %q, want no transient tool action line", line) } } func TestChatToolOutputIsHiddenUntilExpanded(t *testing.T) { fullOutput := strings.Repeat("x", maxCtrlOToolOutputRunes+25) + "tail-marker" m := chatModel{} m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolFinished, ToolName: "bash", Content: fullOutput, }) if len(m.entries) != 1 { t.Fatalf("entries = %d, want 1", len(m.entries)) } if m.entries[0].content != fullOutput { t.Fatal("tool entry should keep full content before rendering") } transcript := m.renderTranscript(100) body := stripANSI(transcript) if strings.Contains(body, "tail-marker") || strings.Contains(body, strings.Repeat("x", 20)) { t.Fatalf("collapsed tool output should be hidden: %q", body) } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) if !m.toolOutputMode || !m.toolOutputOpen || !m.entries[0].expanded { t.Fatalf("ctrl+o should expand tool output inline: %#v", m.entries[0]) } body = stripANSI(m.renderTranscript(100)) if strings.Contains(body, "tail-marker") { t.Fatalf("expanded transcript should cap tool output: %q", body) } if !strings.Contains(body, "...") { t.Fatalf("expanded transcript should show capped output ellipsis: %q", body) } if got := strings.Count(body, "x"); got != maxCtrlOToolOutputRunes-3 { t.Fatalf("expanded transcript x count = %d, want %d:\n%s", got, maxCtrlOToolOutputRunes-3, body) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) body = stripANSI(m.renderTranscript(100)) if m.toolOutputOpen || m.entries[0].expanded || strings.Contains(body, strings.Repeat("x", 20)) { t.Fatalf("second ctrl+o should collapse tool output: %q", body) } } func TestChatCompletedToolsGroupWhenNextStepStarts(t *testing.T) { firstArgs := map[string]any{"command": "pwd"} secondArgs := map[string]any{"command": "ls"} thirdArgs := map[string]any{"command": "date"} m := chatModel{} m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash", Args: firstArgs}) m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-1", ToolName: "bash", Args: firstArgs, Content: "one"}) m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-2", ToolName: "bash", Args: secondArgs}) m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolFinished, ToolCallID: "call-2", ToolName: "bash", Args: secondArgs, Content: "two"}) m.applyAgentEvent(coreagent.Event{Type: coreagent.EventToolStarted, ToolCallID: "call-3", ToolName: "bash", Args: thirdArgs}) if len(m.entries) != 2 { t.Fatalf("entries = %d, want grouped history plus active tool: %#v", len(m.entries), m.entries) } if m.entries[0].role != "tool_group" || len(m.entries[0].tools) != 2 { t.Fatalf("first entry should be grouped tool history: %#v", m.entries[0]) } if line := stripANSI(toolGroupStatusLine(m.entries[0])); line != "Ran 2 commands" { t.Fatalf("grouped command line = %q", line) } if m.entries[1].status != "running" || m.entries[1].label != "Bash(\"date\")" { t.Fatalf("second entry should be active tool: %#v", m.entries[1]) } if line := stripANSI(toolStatusLine(m.entries[1])); line != `Bash("date")` { t.Fatalf("running command line = %q", line) } } func TestChatCompletedToolsGroupAcrossEmptyAssistantEntries(t *testing.T) { entries := groupCompletedToolEntries([]chatEntry{ newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("ls")`, status: "done", content: "listed"}), newChatEntry(chatEntry{role: "assistant"}), newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("agent")`, status: "error", err: "is directory", content: "is directory"}), newChatEntry(chatEntry{role: "assistant", content: " "}), newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("AGENTS.md")`, status: "done", content: "instructions"}), newChatEntry(chatEntry{role: "assistant", content: "The files were listed."}), }) if len(entries) != 2 { t.Fatalf("entries = %d, want grouped tools plus assistant: %#v", len(entries), entries) } if entries[0].role != "tool_group" || len(entries[0].tools) != 3 { t.Fatalf("first entry should group tools across empty assistant entries: %#v", entries[0]) } if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 1 command and read 2 files" { t.Fatalf("grouped tool line = %q", line) } if entries[1].role != "assistant" || entries[1].content != "The files were listed." { t.Fatalf("second entry should keep real assistant content: %#v", entries[1]) } } func TestChatCompletedToolsGroupAcrossCollapsedThinking(t *testing.T) { entries := groupCompletedToolEntries([]chatEntry{ newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done", content: "one"}), newChatEntry(chatEntry{role: "thinking", label: "Thinking", content: "choose next tool", status: "done"}), newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("ls")`, status: "done", content: "two"}), newChatEntry(chatEntry{role: "assistant"}), newChatEntry(chatEntry{role: "tool", detail: "read", label: `Read("AGENTS.md")`, status: "done", content: "instructions"}), }) if len(entries) != 1 { t.Fatalf("entries = %d, want one grouped tool entry: %#v", len(entries), entries) } if entries[0].role != "tool_group" || len(entries[0].tools) != 3 { t.Fatalf("tools should group across collapsed thinking: %#v", entries[0]) } if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 2 commands and read a file" { t.Fatalf("grouped tool line = %q", line) } } func TestChatCtrlOTogglesInlineToolOutput(t *testing.T) { m := chatModel{ width: 100, height: 20, entries: []chatEntry{ {role: "tool", detail: "bash", label: "Bash(\"pwd\")", status: "done", content: "one"}, {role: "assistant", content: "between"}, {role: "tool", detail: "read", label: "Read(\"file\")", status: "error", err: "nope", content: "two"}, }, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) for _, index := range []int{0, 2} { if !m.entries[index].expanded { t.Fatalf("tool entry %d should be expanded inline", index) } } view := stripANSI(m.renderTranscript(100)) if strings.Contains(view, "Tool details") { t.Fatalf("ctrl+o should keep tool output inline: %q", view) } if !strings.Contains(view, "one") || !strings.Contains(view, "two") { t.Fatalf("view missing inline expanded tool output: %q", view) } if !strings.Contains(view, "between") { t.Fatalf("inline tool output should keep surrounding chat visible: %q", view) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) for _, index := range []int{0, 2} { if m.entries[index].expanded { t.Fatalf("tool entry %d should remain collapsed", index) } } } func TestChatCtrlOTogglesInlineOutput(t *testing.T) { m := chatModel{ width: 100, height: 24, entries: []chatEntry{ {role: "user", content: "who is parth sareen"}, {role: "tool", detail: "web_search", label: "Web Search(\"who is Parth Sareen\")", status: "done", content: "Search results"}, {role: "assistant", content: "Based on public search results, Parth Sareen works on AI tooling."}, }, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) if !m.toolOutputOpen || !m.entries[1].expanded { t.Fatalf("tool output should be expanded inline: %#v", m.entries[1]) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) if m.toolOutputOpen || m.entries[1].expanded { t.Fatalf("tool output should be collapsed inline: %#v", m.entries[1]) } } func TestChatCtrlODoesNotExpandThinking(t *testing.T) { m := chatModel{ entries: []chatEntry{ newChatEntry(chatEntry{role: "thinking", label: "Thinking", status: "done", content: "private reasoning"}), }, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) if m.entries[0].expanded { t.Fatalf("ctrl+o should not expand thinking entries: %#v", m.entries[0]) } if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "private reasoning") { t.Fatalf("ctrl+o should not render thinking content:\n%s", view) } m.liveMessages = []api.Message{{Role: "assistant", Thinking: "live private reasoning"}} m.syncThinkingEntry() if m.entries[1].expanded { t.Fatalf("live thinking should not inherit ctrl+o expansion: %#v", m.entries[1]) } if view := stripANSI(m.renderTranscript(100)); strings.Contains(view, "live private reasoning") { t.Fatalf("ctrl+o should not render live thinking content:\n%s", view) } } func TestChatCtrlOCollapseKeepsInputPromptVisible(t *testing.T) { m := chatModel{ width: 80, height: 8, opts: Options{Model: "gemma4"}, entries: []chatEntry{ {role: "user", content: "inspect this"}, {role: "tool", detail: "bash", label: "Bash(\"pwd\")", status: "done", content: strings.Repeat("output\n", 20)}, {role: "assistant", content: "Done."}, }, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) view := stripANSI(m.View()) if !strings.Contains(view, "│ █") { t.Fatalf("input prompt disappeared after collapsing tool output:\n%s", view) } } func TestChatCtrlOShowsRunningToolOutputInline(t *testing.T) { args := map[string]any{"command": "pwd"} m := chatModel{ width: 100, height: 20, running: true, opts: Options{Model: "test-model"}, } m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash", Args: args, }) updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) if !m.entries[0].expanded { t.Fatalf("ctrl+o should expand the running tool inline") } view := stripANSI(m.View()) if !strings.Contains(view, `Bash("pwd")`) || !strings.Contains(view, "$ pwd") { t.Fatalf("expanded transcript missing running tool details:\n%s", view) } m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolFinished, ToolCallID: "call-1", ToolName: "bash", Args: args, Content: "/tmp/project\n", }) view = stripANSI(m.View()) if !strings.Contains(view, "/tmp/project") { t.Fatalf("finished tool output should be visible inline: %q", view) } if !strings.Contains(view, "│ █") { t.Fatalf("input prompt disappeared while tool output is expanded:\n%s", view) } if !strings.Contains(view, "test-model") { t.Fatalf("footer/model line disappeared while tool output is expanded:\n%s", view) } updated, _ = m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) view = stripANSI(m.View()) if !strings.Contains(view, "│ █") { t.Fatalf("input prompt disappeared after collapsing tool output:\n%s", view) } if !strings.Contains(view, "test-model") { t.Fatalf("footer/model line disappeared after collapsing tool output:\n%s", view) } } func TestChatLongCommandLabelTruncatesAndCtrlOShowsFullCommand(t *testing.T) { command := strings.Repeat("x", 120) m := chatModel{ width: 180, height: 20, } m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "bash", Args: map[string]any{"command": command}, }) wantLabel := `Bash("` + strings.Repeat("x", 100) + `...")` if m.entries[0].label != wantLabel { t.Fatalf("label = %q, want %q", m.entries[0].label, wantLabel) } if strings.Contains(m.entries[0].label, strings.Repeat("x", 101)) { t.Fatalf("collapsed label should truncate command: %q", m.entries[0].label) } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) view := stripANSI(m.renderTranscript(180)) if !strings.Contains(view, "$ "+command) { t.Fatalf("expanded ctrl+o view should show full command:\n%s", view) } } func TestChatCtrlOInlineOutputSurvivesToolGrouping(t *testing.T) { firstArgs := map[string]any{"command": "pwd"} secondArgs := map[string]any{"command": "ls"} m := chatModel{ width: 100, height: 24, entries: []chatEntry{ newChatEntry(chatEntry{role: "tool", detail: "bash", label: "Bash(\"pwd\")", status: "done", content: "one", args: firstArgs}), newChatEntry(chatEntry{role: "tool", detail: "bash", label: "Bash(\"ls\")", status: "done", content: "two", args: secondArgs}), }, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolStarted, ToolCallID: "call-3", ToolName: "bash", Args: map[string]any{"command": "date"}, }) if len(m.entries) != 2 { t.Fatalf("entries = %d, want tool group plus active tool: %#v", len(m.entries), m.entries) } if m.entries[0].role != "tool_group" || !m.entries[0].expanded { t.Fatalf("grouped tool history should stay expanded inline: %#v", m.entries[0]) } if line := stripANSI(toolGroupStatusLine(m.entries[0])); line != "Ran 2 commands" { t.Fatalf("grouped command line = %q", line) } view := stripANSI(m.renderTranscript(100)) for _, want := range []string{`Bash("pwd")`, `Bash("ls")`, `Bash("date")`, "one", "two"} { if !strings.Contains(view, want) { t.Fatalf("grouped tool output missing %q:\n%s", want, view) } } if strings.Contains(view, " Ran 1 command") { t.Fatalf("expanded grouped children should show concrete invocations, not generic summaries:\n%s", view) } } func TestChatExpandedToolGroupRendersIndentedToolBlocks(t *testing.T) { startedAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC) m := chatModel{ entries: []chatEntry{ newChatEntry(chatEntry{ role: "tool_group", label: "Tool calls (2)", status: "done", expanded: true, tools: []chatEntry{ newChatEntry(chatEntry{ role: "tool", detail: "web_search", label: "Web Search(\"Parth Sareen\")", status: "done", content: "Search results for: Parth Sareen", startedAt: startedAt, finishedAt: startedAt.Add(823 * time.Millisecond), }), newChatEntry(chatEntry{ role: "tool", detail: "read", label: "Read(\"feedback.md\")", status: "done", content: "Looks good.", startedAt: startedAt.Add(time.Second), finishedAt: startedAt.Add(3 * time.Second), }), }, }), }, } transcript := stripANSI(m.renderTranscript(120)) expected := strings.Join([]string{ " Web Search(\"Parth Sareen\")", " Search results for: Parth Sareen", " ", " Read(\"feedback.md\")", " Looks good.", }, "\n") if !strings.Contains(transcript, expected) { t.Fatalf("expanded tool group did not render expected block spacing:\n%s", transcript) } } func TestChatExpandedToolGroupCapsChildOutput(t *testing.T) { longOutput := strings.Repeat("y", maxCtrlOToolOutputRunes+25) + "group-tail-marker" m := chatModel{ entries: []chatEntry{ newChatEntry(chatEntry{ role: "tool_group", status: "done", expanded: true, tools: []chatEntry{ newChatEntry(chatEntry{ role: "tool", detail: "read", label: "Read(\"big.log\")", status: "done", content: longOutput, }), }, }), }, } transcript := stripANSI(m.renderTranscript(120)) if strings.Contains(transcript, "group-tail-marker") { t.Fatalf("expanded grouped tool output should be capped:\n%s", transcript) } if !strings.Contains(transcript, "...") { t.Fatalf("expanded grouped tool output should show capped output ellipsis:\n%s", transcript) } if got := strings.Count(transcript, "y"); got != maxCtrlOToolOutputRunes-3 { t.Fatalf("expanded grouped tool output y count = %d, want %d:\n%s", got, maxCtrlOToolOutputRunes-3, transcript) } if got := m.entries[0].tools[0].content; got != longOutput { t.Fatal("ctrl+o rendering should not mutate grouped tool content") } } func TestChatToolGroupStatusOmitsResultCounts(t *testing.T) { startedAt := time.Date(2026, 6, 15, 17, 0, 0, 0, time.UTC) entry := newChatEntry(chatEntry{ role: "tool_group", label: "Tool calls (3)", status: "error", startedAt: startedAt, finishedAt: startedAt.Add(3 * time.Second), tools: []chatEntry{ newChatEntry(chatEntry{role: "tool", status: "done"}), newChatEntry(chatEntry{role: "tool", status: "done"}), newChatEntry(chatEntry{role: "tool", status: "error", err: "failed"}), }, }) line := stripANSI(toolGroupStatusLine(entry)) for _, word := range []string{"succeeded", "failed", "done", "in 3s", "3s"} { if strings.Contains(line, word) { t.Fatalf("group status = %q, should not show status word or elapsed %q", line, word) } } if line != "Used 3 tools" { t.Fatalf("group status = %q, want action summary", line) } } func TestChatToolGroupStatusShowsAllSuccessCount(t *testing.T) { entry := newChatEntry(chatEntry{ role: "tool_group", label: "Tool calls (2)", status: "done", tools: []chatEntry{ newChatEntry(chatEntry{role: "tool", status: "done"}), newChatEntry(chatEntry{role: "tool", status: "done"}), }, }) line := stripANSI(toolGroupStatusLine(entry)) if strings.Contains(line, "succeeded") || strings.Contains(line, "done") { t.Fatalf("group status = %q, should not show success count or done", line) } if line != "Used 2 tools" { t.Fatalf("group status = %q, want action summary", line) } } func TestChatToolGroupStatusRecomputesCachedLabel(t *testing.T) { entries := groupCompletedToolEntries([]chatEntry{ newChatEntry(chatEntry{ role: "tool_group", label: "Ran 1 command", status: "done", tools: []chatEntry{ newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done"}), }, }), newChatEntry(chatEntry{ role: "tool", detail: "bash", label: `Bash("ls")`, status: "done", }), }) if len(entries) != 1 || entries[0].role != "tool_group" { t.Fatalf("entries = %#v, want regrouped tool group", entries) } if line := stripANSI(toolGroupStatusLine(entries[0])); line != "Ran 2 commands" { t.Fatalf("group status = %q, want recomputed action summary", line) } } func TestChatToolGroupStatusKeepsHiddenDetectedCount(t *testing.T) { args := map[string]any{"command": "ls"} entry := newChatEntry(chatEntry{ role: "tool_group", label: "Ran 2 commands", status: "done", tools: []chatEntry{ newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("pwd")`, status: "done"}), }, }) entry.tools[0].args = map[string]any{"command": "pwd"} detected := []chatEntry{ newChatEntry(chatEntry{role: "tool", detail: "bash", label: `Bash("ls")`, status: "queued", args: args}), } grouped := groupCompletedToolEntries([]chatEntry{entry}, detected...) if len(grouped) != 1 || grouped[0].role != "tool_group" || len(grouped[0].tools) != 1 { t.Fatalf("grouped entries = %#v, want visible finished child only", grouped) } if line := stripANSI(toolGroupStatusLine(grouped[0])); line != "Ran 2 commands" { t.Fatalf("group status = %q, want hidden detected command counted", line) } } func TestChatToolOutputRendersUnifiedDiff(t *testing.T) { diff := strings.Join([]string{ "diff --git a/file.go b/file.go", "index 1111111..2222222 100644", "--- a/file.go", "+++ b/file.go", "@@ -1,3 +1,3 @@", " package main", "-var old = true", "+var newer = true", }, "\n") m := chatModel{ entries: []chatEntry{{ role: "tool", detail: "bash", label: "Bash(\"git diff\")", status: "done", content: diff, }}, } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) rendered := m.renderTranscript(100) body := stripANSI(rendered) if !strings.Contains(body, "diff --git a/file.go b/file.go") || !strings.Contains(body, "-var old = true") || !strings.Contains(body, "+var newer = true") { t.Fatalf("rendered diff missing expected lines: %q", body) } if !looksLikeUnifiedDiff(diff) { t.Fatal("diff output should be detected as a unified diff") } } func TestChatToolCallRendersPrettyInvocationAndResult(t *testing.T) { args := api.NewToolCallFunctionArguments() args.Set("query", "Parth Sareen Ollama software engineer") m := chatModel{width: 100, height: 30} m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolCallDetected, ToolCalls: []api.ToolCall{{ ID: "call-1", Function: api.ToolCallFunction{ Name: "web_search", Arguments: args, }, }}, }) m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolStarted, ToolCallID: "call-1", ToolName: "web_search", Args: args.ToMap(), }) m.applyAgentEvent(coreagent.Event{ Type: coreagent.EventToolFinished, ToolCallID: "call-1", ToolName: "web_search", Args: args.ToMap(), Content: "**Search results for:** Parth Sareen\n\n1. Parth Sareen\n URL: https://parthsareen.com\n", }) transcript := stripANSI(m.renderTranscript(100)) if !strings.Contains(transcript, "Web Search(\"Parth Sareen Ollama software engineer\")") { t.Fatalf("transcript missing invocation: %q", transcript) } for _, word := range []string{"done", "in 6s", "6s"} { if strings.Contains(transcript, word) { t.Fatalf("transcript should not include status word/elapsed %q: %q", word, transcript) } } if strings.Contains(transcript, "https://parthsareen.com") || strings.Contains(transcript, "Search results for:") { t.Fatalf("tool output should be collapsed by default: %q", transcript) } if strings.Contains(transcript, "web_search") { t.Fatalf("transcript should use display name instead of raw tool name: %q", transcript) } if m.entries[0].status != "done" { t.Fatalf("tool status = %q, want done", m.entries[0].status) } updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO}) m = updated.(chatModel) view := stripANSI(m.renderTranscript(100)) if !strings.Contains(view, "**Search results for:**") || !strings.Contains(view, "https://parthsareen.com") { t.Fatalf("inline web output missing content: %q", view) } } func TestChatToolOutputHidesInternalTruncationMarker(t *testing.T) { entry := chatEntry{ role: "tool", detail: "bash", status: "done", expanded: true, content: "head\n\n[tool output truncated: showing first ~10 tokens and last ~10 tokens; omitted ~25 tokens. Use a narrower command, line range, or search query if more detail is needed.]\n\ntail", } rendered := stripANSI(strings.Join(renderToolResultLines(entry, 80), "\n")) if strings.Contains(rendered, "tool output truncated") || strings.Contains(rendered, "omitted ~25 tokens") { t.Fatalf("internal truncation marker should be hidden from tool output: %q", rendered) } if !strings.Contains(rendered, "head") || !strings.Contains(rendered, "tail") { t.Fatalf("tool output should keep visible content: %q", rendered) } } func TestChatRenderTranscriptCachesEntryLinesUntilDirty(t *testing.T) { m := chatModel{ entries: []chatEntry{ newChatEntry(chatEntry{role: "assistant", content: "**hello**"}), }, } _ = m.renderTranscript(80) if len(m.entries[0].renderLines) == 0 { t.Fatal("rendered entry lines should be cached") } m.entries[0].renderLines = []string{"cached line"} if got := stripANSI(m.renderTranscript(80)); !strings.Contains(got, "cached line") { t.Fatalf("render should reuse cached lines for unchanged entry: %q", got) } m.entries[0].content = "**changed**" m.markEntryDirty(0) if got := stripANSI(m.renderTranscript(80)); strings.Contains(got, "cached line") || !strings.Contains(got, "changed") { t.Fatalf("dirty entry should re-render instead of using stale cache: %q", got) } } func TestWrapChatTextSplitsLongLines(t *testing.T) { lines := wrapChatText("alpha beta gamma delta", 12) if len(lines) < 2 { t.Fatalf("lines = %#v, want split text", lines) } if strings.Contains(lines[0], "delta") { t.Fatalf("first line was not wrapped: %#v", lines) } } func TestWrapChatTextUsesDisplayWidth(t *testing.T) { lines := wrapChatText(strings.Repeat("界", 20), 20) if len(lines) < 2 { t.Fatalf("lines = %#v, want full-width text split", lines) } for _, line := range lines { if got := lipgloss.Width(line); got > 20 { t.Fatalf("line %q width = %d, want <= 20", line, got) } } } func TestRenderMarkdownTableWrapsLongCells(t *testing.T) { markdown := strings.Join([]string{ "| # | Item | Why it matters |", "|---|---|---|", "| **C** | **Bash filesystem/network confinement** | Biggest asymmetry: file tools are sandboxed via `os.Root`, bash is not and this text should survive until tail-token. |", }, "\n") rendered := renderMarkdownForView(markdown, 72) plain := stripANSI(rendered) if !strings.Contains(plain, "tail-token") { t.Fatalf("long table cell was truncated:\n%s", plain) } if strings.Contains(plain, "tail-toke...") { t.Fatalf("long table cell should wrap, not ellipsize:\n%s", plain) } for _, line := range strings.Split(rendered, "\n") { if got := lipgloss.Width(line); got > 72 { t.Fatalf("rendered table line width = %d, want <= 72: %q\n%s", got, stripANSI(line), plain) } } }