package tui
import (
"encoding/json"
"strings"
"testing"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
func TestAppendDeltaCoalesces(t *testing.T) {
tr := newTranscript(80)
tr.appendDelta("Hel")
tr.appendDelta("lo ")
tr.appendDelta("world")
if len(tr.entries) != 1 {
t.Fatalf("streamed text made %d entries, want 1", len(tr.entries))
}
if got := tr.entries[0].text; got != "Hello world" {
t.Errorf("text = %q, want %q", got, "Hello world")
}
}
// A tool call arriving mid-sentence must close the streaming entry rather than
// interleave with it: this is the bug the line renderer's midLine flag exists
// to prevent, and the transcript has to handle it structurally.
func TestToolCallClosesStreamingText(t *testing.T) {
tr := newTranscript(80)
tr.appendDelta("let me look\n")
tr.startTool("grep", "pattern=foo")
tr.appendDelta("found it")
if len(tr.entries) != 3 {
t.Fatalf("got %d entries, want 3", len(tr.entries))
}
if tr.entries[0].open {
t.Error("first text entry still open after a tool call")
}
if tr.entries[0].text != "let me look" {
t.Errorf("trailing newline not trimmed: %q", tr.entries[0].text)
}
if tr.entries[2].kind != entryAssistant {
t.Error("text after a tool call should start a new assistant entry")
}
}
func TestFinishToolMatchesMostRecentOpenCall(t *testing.T) {
tr := newTranscript(80)
tr.startTool("read_file", "path=a.go")
tr.finishTool("read_file", "1: package a", false)
tr.startTool("read_file", "path=b.go")
tr.finishTool("read_file", "1: package b", false)
if got := tr.entries[0].result; got != "1: package a" {
t.Errorf("first call result overwritten: %q", got)
}
if got := tr.entries[1].result; got != "1: package b" {
t.Errorf("second call result = %q", got)
}
for i, e := range tr.entries {
if e.open {
t.Errorf("entry %d still open", i)
}
}
}
// A result with no matching call still has to appear. Dropping it would hide
// evidence of what the agent actually did.
func TestFinishToolWithoutStartStillRecords(t *testing.T) {
tr := newTranscript(80)
tr.finishTool("bash", "boom", true)
if len(tr.entries) != 1 {
t.Fatalf("got %d entries, want 1", len(tr.entries))
}
if !tr.entries[0].isError || tr.entries[0].result != "boom" {
t.Errorf("orphan result not recorded: %+v", tr.entries[0])
}
}
func TestRenderCollapsesLongOutputAndVerboseExpands(t *testing.T) {
long := strings.Repeat("line\n", 40)
tr := newTranscript(100)
tr.startTool("bash", "command=go test ./...")
tr.finishTool("bash", long, false)
out := tr.render()
if !strings.Contains(out, "more lines") {
t.Error("long output was not collapsed")
}
tr.setVerbose(true)
if got := tr.render(); strings.Contains(got, "more lines") {
t.Error("verbose mode still collapsed the output")
}
}
func TestRenderCachesUntilDirty(t *testing.T) {
tr := newTranscript(80)
tr.addUser("hello")
first := tr.render()
if tr.dirty {
t.Fatal("still dirty after render")
}
tr.addUser("again")
if !tr.dirty {
t.Fatal("adding an entry did not invalidate the cache")
}
if second := tr.render(); second == first {
t.Error("render returned stale content after a new entry")
}
}
func TestSetWidthInvalidatesCache(t *testing.T) {
tr := newTranscript(80)
tr.addUser("hello")
tr.render()
tr.setWidth(80) // unchanged: must not dirty
if tr.dirty {
t.Error("setting the same width invalidated the cache")
}
tr.setWidth(40)
if !tr.dirty {
t.Error("a width change did not invalidate the cache")
}
}
func TestDiffIsRenderedAsDiff(t *testing.T) {
diff := "--- a/x.go\n+++ b/x.go\n@@ -1 +1 @@\n-old\n+new\n"
if !looksLikeDiff(diff) {
t.Fatal("looksLikeDiff rejected a unified diff")
}
if looksLikeDiff("just a message") {
t.Error("looksLikeDiff accepted plain text")
}
tr := newTranscript(80)
tr.startTool("edit_file", "path=x.go")
tr.finishTool("edit_file", diff, false)
out := tr.render()
for _, want := range []string{"-old", "+new", "@@ -1 +1 @@"} {
if !strings.Contains(out, want) {
t.Errorf("diff line %q missing from render", want)
}
}
// The ⎿ prefix belongs to summarised output, not to a diff.
if strings.Contains(out, "⎿") {
t.Error("diff was rendered through the summary path")
}
}
func TestSummarizeInputIsSorted(t *testing.T) {
in := json.RawMessage(`{"path":"x.go","limit":10,"offset":2}`)
if got, want := summarizeInput(in), "limit=10, offset=2, path=x.go"; got != want {
t.Errorf("summarizeInput = %q, want %q", got, want)
}
}
func TestSummarizeInputFlattensNewlines(t *testing.T) {
in := json.RawMessage(`{"command":"go build\ngo test"}`)
if got := summarizeInput(in); strings.Contains(got, "\n") {
t.Errorf("newline survived into the header line: %q", got)
}
}
func TestSummarizeInputHandlesGarbage(t *testing.T) {
if got := summarizeInput(json.RawMessage(`not json`)); got != "not json" {
t.Errorf("garbage input = %q, want it passed through", got)
}
if got := summarizeInput(nil); got != "" {
t.Errorf("nil input = %q, want empty", got)
}
}
func TestTruncateCountsRunesNotBytes(t *testing.T) {
// Slicing this by bytes would produce invalid UTF-8.
if got := truncate("日本語テキスト", 4); len([]rune(got)) != 4 {
t.Errorf("truncate returned %d runes, want 4 (%q)", len([]rune(got)), got)
}
if got := truncate("short", 50); got != "short" {
t.Errorf("truncate shortened a short string: %q", got)
}
if got := truncate("abc", 0); got != "" {
t.Errorf("truncate(_, 0) = %q, want empty", got)
}
}
// An approval gates a call before it runs, so it must render with the call
// rather than after the result that came back later.
func TestAnnotateToolAttachesToTheGatedCall(t *testing.T) {
tr := newTranscript(80)
tr.startTool("bash", "command=go build ./...")
tr.annotateTool("bash", "approved once")
tr.finishTool("bash", "ok", false)
if len(tr.entries) != 1 {
t.Fatalf("got %d entries, want the note folded into the call", len(tr.entries))
}
if tr.entries[0].note != "approved once" {
t.Errorf("note = %q", tr.entries[0].note)
}
out := tr.render()
note, result := strings.Index(out, "approved once"), strings.Index(out, "ok")
if note < 0 || result < 0 {
t.Fatalf("note or result missing from render:\n%s", out)
}
if note > result {
t.Error("the approval rendered after the result it gated")
}
}
func TestAnnotateToolWithoutOpenCallFallsBackToANotice(t *testing.T) {
tr := newTranscript(80)
tr.annotateTool("bash", "denied")
if len(tr.entries) != 1 || tr.entries[0].kind != entryNotice {
t.Fatalf("orphan note not recorded: %+v", tr.entries)
}
}
// Styled text measured by rune count treats every escape byte as a visible
// cell, which silently eats the end of a status line.
func TestTruncateStyledMeasuresDisplayWidth(t *testing.T) {
styled := styDim.Render("ready") + " · " + styCoral.Render("$0.0241")
plain := "ready · $0.0241"
// Compare display width against display width: len() would count the
// two-byte · as two cells and make this assertion wrong rather than strict.
if got, want := lipgloss.Width(truncateStyled(styled, 40)), lipgloss.Width(plain); got != want {
t.Errorf("width %d, want %d — styled text was cut though it fits", got, want)
}
if got := truncateStyled(styled, 40); !strings.Contains(got, "$0.0241") {
t.Errorf("cost was truncated away though it fits: %q", got)
}
if got := truncateStyled(styled, 8); lipgloss.Width(got) > 8 {
t.Errorf("width %d exceeds the limit of 8", lipgloss.Width(got))
}
if got := truncateStyled(styled, 0); got != "" {
t.Errorf("zero width = %q, want empty", got)
}
}
func TestRenderNarrowWidthDoesNotPanic(t *testing.T) {
tr := newTranscript(1)
tr.addUser("a reasonably long line that must wrap somewhere")
tr.startTool("bash", "command=echo hi")
tr.finishTool("bash", "hi", false)
tr.addError("boom")
_ = tr.render()
}
// Assistant text is Markdown and the transcript renders it as such. The dot
// gutter survives it: a heading is still one event hanging off one dot, with
// everything under it indented to match.
func TestAssistantTextRendersAsMarkdown(t *testing.T) {
tr := newTranscript(60)
tr.appendDelta("# Findings\n\nThe **refresh** path drops the token.\n")
tr.closeOpen()
out := ansi.Strip(tr.render())
for _, markup := range []string{"#", "**"} {
if strings.Contains(out, markup) {
t.Errorf("markup %q printed rather than rendered:\n%s", markup, out)
}
}
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if !strings.HasPrefix(lines[0], dot+" ") {
t.Errorf("first line lost its dot: %q", lines[0])
}
for _, line := range lines[1:] {
if strings.TrimSpace(line) != "" && !strings.HasPrefix(line, gutter) {
t.Errorf("continuation line not indented under the dot: %q", line)
}
}
}
// A rendered entry is cached, so the three things that change what it should
// look like all have to throw that cache away. Each of these is a way the
// scrollback could show something that was true one frame ago.
func TestRenderedMarkdownIsInvalidated(t *testing.T) {
t.Run("by more text arriving", func(t *testing.T) {
tr := newTranscript(60)
tr.appendDelta("The **refresh** path")
_ = tr.render()
tr.appendDelta(" drops the token.")
if got := ansi.Strip(tr.render()); !strings.Contains(got, "drops the token") {
t.Errorf("streamed text missing after a re-render:\n%s", got)
}
})
t.Run("by a resize", func(t *testing.T) {
tr := newTranscript(100)
tr.appendDelta(strings.Repeat("word ", 40))
tr.closeOpen()
wide := len(strings.Split(tr.render(), "\n"))
tr.setWidth(40)
if narrow := len(strings.Split(tr.render(), "\n")); narrow <= wide {
t.Errorf("narrowing the terminal did not rewrap: %d lines at 100, %d at 40", wide, narrow)
}
})
t.Run("by a theme change", func(t *testing.T) {
forceColour(t)
t.Cleanup(func() { applyTheme(defaultTheme) })
tr := newTranscript(60)
tr.appendDelta("# Findings")
tr.closeOpen()
applyTheme("ember")
ember := tr.render()
applyTheme("iris")
if iris := tr.render(); iris == ember {
t.Error("the transcript kept the old theme's colours after /setup changed it")
}
})
}
// Markdown puts blank lines between blocks, and the gutter must not turn them
// into lines of trailing whitespace.
func TestBlankLinesCarryNoGutter(t *testing.T) {
tr := newTranscript(60)
tr.appendDelta("# Findings\n\nThe refresh path drops the token.")
tr.closeOpen()
for _, line := range strings.Split(ansi.Strip(tr.render()), "\n") {
if strings.TrimSpace(line) == "" && line != "" {
t.Errorf("blank line padded to %q", line)
}
}
}