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