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)
		}
	}
}
