package tui import ( "regexp" "strings" "testing" tea "charm.land/bubbletea/v2" "github.com/smallnest/pigo/internal/agentcore" "github.com/smallnest/pigo/internal/cli/ui" ) // ansiRE strips SGR escape sequences so tests can inspect the raw text the // transcript stored, independent of the theme's coloring. var ansiRE = regexp.MustCompile("\x1b\\[[0-9;]*m") func stripANSI(s string) string { return ansiRE.ReplaceAllString(s, "") } // apply runs one Update tick and returns the concrete Model, failing on an // unexpected model type. It keeps the streaming tests terse. func apply(t *testing.T, m tea.Model, msg tea.Msg) Model { t.Helper() next, _ := m.Update(msg) got, ok := next.(Model) if !ok { t.Fatalf("Update returned %T, want tui.Model", next) } return got } // TestTranscriptStreamingConcat feeds a run of text deltas then a turn end and // asserts the assistant block accumulates the deltas in order and the joined // text is rendered in the View. func TestTranscriptStreamingConcat(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, textDeltaMsg{delta: "Hello "}) m = apply(t, m, textDeltaMsg{delta: "world"}) m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{ Content: agentcore.ContentList{agentcore.NewTextContent("Hello world")}, }}) if n := len(m.transcript.blocks); n != 1 { t.Fatalf("block count = %d, want 1 assistant block", n) } if got := m.transcript.blocks[0]; got.role != roleAssistant || got.text != "Hello world" { t.Errorf("assistant block = %+v, want role assistant text %q", got, "Hello world") } if content := stripANSI(m.View().Content); !strings.Contains(content, "Hello world") { t.Errorf("rendered View missing streamed text; got:\n%s", content) } // The turn was finalized, so a fresh delta starts a NEW assistant block. if m.transcript.activeAssistant != -1 { t.Errorf("activeAssistant = %d after turn end, want -1", m.transcript.activeAssistant) } } // TestTranscriptSurfacesTurnError verifies a turn that ends with stopReason // error surfaces the provider's error message as a system block rather than // finalizing an empty turn and returning silently to the prompt. The loop // delivers request failures (e.g. a 4xx) this way — as a terminal assistant // message via TurnEndEvent, not as the run's result error — so without the // StopReason check in the turnEndMsg handler the TUI would show nothing at all. func TestTranscriptSurfacesTurnError(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{ StopReason: agentcore.StopReasonError, ErrorMessage: "upstream 401: 无效的令牌", }}) var sys string for _, b := range m.transcript.blocks { if b.role == roleSystem { sys = b.text } } if !strings.Contains(sys, "error:") || !strings.Contains(sys, "upstream 401: 无效的令牌") { t.Errorf("turn error not surfaced; system block = %q", sys) } if content := stripANSI(m.View().Content); !strings.Contains(content, "upstream 401") { t.Errorf("rendered View missing the surfaced error; got:\n%s", content) } } // TestTranscriptSurfacesAbortedTurn verifies a turn that ends with stopReason // aborted is flagged rather than returning silently. func TestTranscriptSurfacesAbortedTurn(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{ StopReason: agentcore.StopReasonAborted, }}) var sys string for _, b := range m.transcript.blocks { if b.role == roleSystem { sys = b.text } } if !strings.Contains(sys, "aborted") { t.Errorf("aborted turn not surfaced; system block = %q", sys) } } // TestTranscriptNotesEmptyResponse verifies a clean end_turn that produced no // content and no tool results is flagged with a note (with a provider-mismatch // hint) instead of returning silently to the prompt — the shape produced when an // endpoint accepts the request with a 200 but returns nothing decodable. func TestTranscriptNotesEmptyResponse(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{ StopReason: agentcore.StopReasonEndTurn, }}) var sys string for _, b := range m.transcript.blocks { if b.role == roleSystem { sys = b.text } } if !strings.Contains(sys, "empty response from the model") { t.Errorf("empty response not flagged; system block = %q", sys) } } // TestTranscriptCleanTurnNoNote verifies a normal turn with content does NOT add // a spurious error/empty system note. func TestTranscriptCleanTurnNoNote(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 40, Height: 12}) m = apply(t, m, turnEndMsg{msg: agentcore.AssistantMessage{ StopReason: agentcore.StopReasonEndTurn, Content: agentcore.ContentList{agentcore.NewTextContent("the answer")}, }}) for _, b := range m.transcript.blocks { if b.role == roleSystem { t.Errorf("clean turn should add no system note, got %q", b.text) } } } // TestTranscriptAutoStick verifies the stick-to-bottom rule: while the viewport // is at the bottom, new content keeps it pinned there; once the user scrolls up, // streamed content no longer forces a jump to the bottom — but submitting a new // turn (addUser) re-arms follow and snaps back to the newest output. func TestTranscriptAutoStick(t *testing.T) { tr := newTranscript(DefaultTheme()) tr.setSize(20, 3) // 3 visible rows for i := 0; i < 6; i++ { tr.addUser("line") } if !tr.vp.AtBottom() { t.Fatal("transcript should stick to the bottom while at the bottom") } // More content while pinned keeps it pinned. tr.addUser("more") if !tr.vp.AtBottom() { t.Fatal("new content should keep a bottom-pinned transcript at the bottom") } // Simulate a user scroll-up through the viewport's key handling. tr.update(tea.KeyPressMsg{Code: tea.KeyUp}) if tr.vp.AtBottom() { t.Fatal("scrolling up should move the viewport off the bottom") } // Streamed content arriving while scrolled up must NOT yank the view back to // the bottom — the user is reading history. tr.appendDelta("streamed while reading history\nsecond line\nthird line") if tr.vp.AtBottom() { t.Error("auto-stick should stay paused after the user scrolls up") } // Submitting a new turn is an explicit action: it re-arms follow and snaps // back to the newest output so the reply is never left off-screen. tr.addUser("a brand new prompt") if !tr.vp.AtBottom() { t.Error("submitting a new turn should re-arm auto-scroll to the bottom") } } // TestTranscriptCJKWrap feeds a long CJK line into a narrow transcript and // asserts every wrapped line fits the width in display columns (not bytes) and // that no rune was dropped or split. func TestTranscriptCJKWrap(t *testing.T) { const width = 10 tr := newTranscript(DefaultTheme()) tr.setSize(width, 20) line := strings.Repeat("你好世界", 5) // 20 CJK runes = 40 display columns tr.addUser(line) content := tr.vp.GetContent() lines := strings.Split(content, "\n") if len(lines) < 2 { t.Fatalf("expected the CJK line to wrap onto multiple rows, got %d line(s)", len(lines)) } for i, ln := range lines { if w := ui.Width(ln); w > width { t.Errorf("wrapped line %d width = %d columns, want <= %d: %q", i, w, width, stripANSI(ln)) } } // No rune was cut or dropped: every source rune survives the wrap. if got := strings.Count(stripANSI(content), "你"); got != 5 { t.Errorf("counted %d 你 runes after wrap, want 5", got) } } // TestTranscriptScrollbar verifies the scrollbar policy: the gutter is hidden // while the content fits (nothing to scroll) and appears only once the content // overflows. When overflowing, a rounded pill thumb (body "█" with half-block // caps "▄"/"▀") sits alongside the thin groove "│". func TestTranscriptScrollbar(t *testing.T) { tr := newTranscript(DefaultTheme()) tr.setSize(20, 4) // 4 visible rows // Two short lines fit in 4 rows: no scrollbar at all — no thumb, no groove. tr.addUser("one") tr.addUser("two") if tr.overflowing() { t.Fatal("transcript should not overflow while content fits") } fit := stripANSI(tr.view()) if strings.ContainsAny(fit, "█▄▀│") { t.Errorf("expected no scrollbar glyphs while content fits; got:\n%q", fit) } // Enough lines to exceed 4 rows: now it overflows, thumb shrinks and the // groove appears. for i := 0; i < 10; i++ { tr.addUser("line") } if !tr.overflowing() { t.Fatal("transcript should overflow once content exceeds the viewport") } view := stripANSI(tr.view()) if !strings.Contains(view, "▄") || !strings.Contains(view, "▀") { t.Errorf("expected a rounded pill thumb (▄ top, ▀ bottom) while overflowing; got:\n%q", view) } if !strings.Contains(view, "│") { t.Errorf("expected a groove │ while overflowing; got:\n%q", view) } if strings.Contains(view, "░") { t.Errorf("scrollbar no longer uses the shaded track ░; got:\n%q", view) } } // TestTranscriptScrollToRow checks the click/drag mapping: pressing the top of // the gutter scrolls to the top, the bottom scrolls to the bottom, and it is a // no-op when the content fits. func TestTranscriptScrollToRow(t *testing.T) { tr := newTranscript(DefaultTheme()) tr.setSize(20, 4) // Content fits: dragging must not move a non-scrollable viewport. tr.addUser("only line") tr.scrollToRow(3) if tr.vp.YOffset() != 0 { t.Errorf("scrollToRow on non-overflowing viewport moved offset to %d, want 0", tr.vp.YOffset()) } for i := 0; i < 20; i++ { tr.addUser("line") } if !tr.overflowing() { t.Fatal("expected overflow after filling the transcript") } tr.scrollToRow(0) if !tr.vp.AtTop() { t.Errorf("dragging to row 0 should scroll to the top; YOffset=%d", tr.vp.YOffset()) } tr.scrollToRow(tr.viewportHeight() - 1) if !tr.vp.AtBottom() { t.Errorf("dragging to the last row should scroll to the bottom; YOffset=%d", tr.vp.YOffset()) } } // TestModelScrollbarDrag drives the model with mouse press/motion/release on the // scrollbar column and asserts the drag state toggles and the viewport scrolls. func TestModelScrollbarDrag(t *testing.T) { m := apply(t, NewModel(Options{}), tea.WindowSizeMsg{Width: 30, Height: 8}) for i := 0; i < 40; i++ { m.transcript.addUser("line") } if !m.transcript.overflowing() { t.Fatal("expected the transcript to overflow") } col := m.width - 1 // Press at the top of the gutter: drag begins and the view jumps to the top. m = apply(t, m, tea.MouseClickMsg{X: col, Y: 0, Button: tea.MouseLeft}) if !m.draggingScrollbar { t.Fatal("left press on the scrollbar column should start dragging") } if !m.transcript.vp.AtTop() { t.Errorf("press at row 0 should scroll to top; YOffset=%d", m.transcript.vp.YOffset()) } // Motion to the bottom row while held drags the thumb down. m = apply(t, m, tea.MouseMotionMsg{X: col, Y: m.transcript.viewportHeight() - 1, Button: tea.MouseLeft}) if !m.transcript.vp.AtBottom() { t.Errorf("motion to the last row while dragging should scroll to bottom; YOffset=%d", m.transcript.vp.YOffset()) } // Release ends the drag. m = apply(t, m, tea.MouseReleaseMsg{X: col, Y: 3, Button: tea.MouseLeft}) if m.draggingScrollbar { t.Error("release should end the scrollbar drag") } // A press away from the gutter column must not start a drag. m = apply(t, m, tea.MouseClickMsg{X: 0, Y: 0, Button: tea.MouseLeft}) if m.draggingScrollbar { t.Error("press off the scrollbar column should not start dragging") } }