Files
kit/internal/ui/model_test.go
T
Ed Zynda 9a662d440c fix(ui): reduce TUI visual noise and improve layout
- remove "You" label and icon from user messages, use borderless content block
- remove input title bar ("Enter your prompt...") and hint line
- increase textarea from 3 to 4 rows with top/bottom margin
- hide input hints permanently for a cleaner UI
- match separator colors (use theme.Border for both startup and input dividers)
- make startup separator full terminal width instead of hardcoded 80
- add /help for help hint and pipe separators to status bar
- add printCustomMessage/RenderCustomMessage for custom alert labels
- render /help output as markdown with "Help" alert label
- add Ctrl+V (paste image) to help message keys section
- fix reasoning text wrapping using ANSI-aware lipgloss.Style.Width
- export HighlightFileTokens for cross-package use
2026-04-22 11:41:09 +03:00

1142 lines
37 KiB
Go

package ui
import (
"errors"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/mark3labs/kit/internal/app"
"github.com/mark3labs/kit/internal/session"
"github.com/mark3labs/kit/internal/ui/core"
kit "github.com/mark3labs/kit/pkg/kit"
)
// --------------------------------------------------------------------------
// Stub AppController
// --------------------------------------------------------------------------
// stubAppController is a minimal implementation of AppController for tests.
// It records which methods were called and allows the test to inspect the results.
type stubAppController struct {
runCalls []string
cancelCalled int
clearQueueCalled int
clearMsgCalled int
queueLen int
}
func (s *stubAppController) Run(prompt string) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
func (s *stubAppController) CancelCurrentStep() {
s.cancelCalled++
}
func (s *stubAppController) QueueLength() int {
return s.queueLen
}
func (s *stubAppController) ClearQueue() {
s.clearQueueCalled++
s.queueLen = 0
}
func (s *stubAppController) ClearMessages() {
s.clearMsgCalled++
}
func (s *stubAppController) ReloadMessagesFromTree() {
// no-op in tests
}
func (s *stubAppController) CompactConversation(_ string) error {
return nil
}
func (s *stubAppController) GetTreeSession() *session.TreeManager {
return nil
}
func (s *stubAppController) SwitchTreeSession(_ *session.TreeManager) {
// no-op in tests
}
func (s *stubAppController) SendEvent(_ tea.Msg) {
// no-op in tests
}
func (s *stubAppController) AddContextMessage(_ string) {
// no-op in tests
}
func (s *stubAppController) RunWithFiles(prompt string, _ []kit.LLMFilePart) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
func (s *stubAppController) Steer(prompt string) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
func (s *stubAppController) SteerWithFiles(prompt string, _ []kit.LLMFilePart) int {
s.runCalls = append(s.runCalls, prompt)
return s.queueLen
}
// --------------------------------------------------------------------------
// Stub child components
// --------------------------------------------------------------------------
// stubStreamComponent satisfies streamComponentIface without rendering anything.
type stubStreamComponent struct {
resetCalled int
lastMsg tea.Msg
renderedContent string // returned by GetRenderedContent
}
func (s *stubStreamComponent) Init() tea.Cmd { return nil }
func (s *stubStreamComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.lastMsg = msg
return s, nil
}
func (s *stubStreamComponent) View() tea.View { return tea.NewView("") }
func (s *stubStreamComponent) Reset() { s.resetCalled++; s.renderedContent = "" }
func (s *stubStreamComponent) GetRenderedContent() string { return s.renderedContent }
func (s *stubStreamComponent) SpinnerView() string { return "" }
func (s *stubStreamComponent) SetThinkingVisible(bool) {}
func (s *stubStreamComponent) HasReasoning() bool { return false }
func (s *stubStreamComponent) UpdateTheme() {}
// stubInputComponent satisfies inputComponentIface without rendering anything.
type stubInputComponent struct {
lastMsg tea.Msg
}
func (s *stubInputComponent) Init() tea.Cmd { return nil }
func (s *stubInputComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
s.lastMsg = msg
return s, nil
}
func (s *stubInputComponent) View() tea.View { return tea.NewView("") }
// --------------------------------------------------------------------------
// newTestAppModel creates an AppModel with stub children for unit tests.
// --------------------------------------------------------------------------
func newTestAppModel(ctrl AppController) (*AppModel, *stubStreamComponent, *stubInputComponent) {
stream := &stubStreamComponent{}
input := &stubInputComponent{}
m := &AppModel{
state: stateInput,
appCtrl: ctrl,
stream: stream,
input: input,
renderer: newMessageRenderer(80, false),
modelName: "test-model",
width: 80,
height: 24,
streamingBashMaxLines: 50, // Initialize buffer cap like NewAppModel does
scrollList: NewScrollList(80, 20),
messages: []MessageItem{},
}
return m, stream, input
}
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
// sendMsg calls m.Update once with the given message and returns the updated model.
func sendMsg(m *AppModel, msg tea.Msg) *AppModel {
updated, _ := m.Update(msg)
result := updated.(*AppModel)
// Simulate BubbleTea's frame cycle: View() is called after every Update().
// This flushes any pending layoutDirty work (e.g. distributeHeight).
_ = result.View()
return result
}
// --------------------------------------------------------------------------
// State transitions
// --------------------------------------------------------------------------
// TestStateTransition_InputToWorking verifies that a submitMsg while in
// stateInput transitions the model to stateWorking.
func TestStateTransition_InputToWorking(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
if m.state != stateInput {
t.Fatalf("expected stateInput, got %v", m.state)
}
m = sendMsg(m, core.SubmitMsg{Text: "hello"})
if m.state != stateWorking {
t.Fatalf("expected stateWorking after submitMsg, got %v", m.state)
}
if len(ctrl.runCalls) != 1 || ctrl.runCalls[0] != "hello" {
t.Fatalf("expected Run called with 'hello', got %v", ctrl.runCalls)
}
}
// TestStateTransition_WorkingToInput_StepComplete verifies that StepCompleteEvent
// transitions from stateWorking back to stateInput and keeps stream content
// visible (deferred flush — no Reset until next SpinnerEvent{Show: true}).
func TestStateTransition_WorkingToInput_StepComplete(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, app.StepCompleteEvent{ResponseText: "all done"})
if m.state != stateInput {
t.Fatalf("expected stateInput after StepCompleteEvent, got %v", m.state)
}
if stream.resetCalled != 0 {
t.Fatalf("expected stream NOT reset on StepCompleteEvent (deferred flush), got %d resets", stream.resetCalled)
}
}
// TestStateTransition_WorkingToInput_StepError verifies that StepErrorEvent
// transitions from stateWorking back to stateInput and keeps stream content
// visible (deferred flush — no Reset until next SpinnerEvent{Show: true}).
func TestStateTransition_WorkingToInput_StepError(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("something broke")})
if m.state != stateInput {
t.Fatalf("expected stateInput after StepErrorEvent, got %v", m.state)
}
if stream.resetCalled != 0 {
t.Fatalf("expected stream NOT reset on StepErrorEvent (deferred flush), got %d resets", stream.resetCalled)
}
}
// TestStepError_nilErr verifies that a StepErrorEvent with a nil error does not
// panic and still transitions to stateInput.
func TestStepError_nilErr(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, app.StepErrorEvent{Err: nil})
if m.state != stateInput {
t.Fatalf("expected stateInput for nil-error StepErrorEvent, got %v", m.state)
}
}
// --------------------------------------------------------------------------
// StepCancelledEvent
// --------------------------------------------------------------------------
// TestStateTransition_WorkingToInput_StepCancelled verifies that StepCancelledEvent
// transitions from stateWorking back to stateInput and keeps stream content
// visible (deferred flush — no Reset until next SpinnerEvent{Show: true}).
func TestStateTransition_WorkingToInput_StepCancelled(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, app.StepCancelledEvent{})
if m.state != stateInput {
t.Fatalf("expected stateInput after StepCancelledEvent, got %v", m.state)
}
if stream.resetCalled != 0 {
t.Fatalf("expected stream NOT reset on StepCancelledEvent (deferred flush), got %d resets", stream.resetCalled)
}
}
// TestStepCancelled_clearsCanceling verifies that StepCancelledEvent clears
// the canceling flag.
func TestStepCancelled_clearsCanceling(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m.canceling = true
m = sendMsg(m, app.StepCancelledEvent{})
if m.canceling {
t.Fatal("expected canceling=false after StepCancelledEvent")
}
}
// TestStepCancelled_preservesStreamContent verifies that StepCancelledEvent
// does NOT flush stream content — it stays visible for deferred flush.
func TestStepCancelled_preservesStreamContent(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
stream.renderedContent = "partial assistant response"
_ = sendMsg(m, app.StepCancelledEvent{})
if stream.renderedContent != "partial assistant response" {
t.Fatal("expected stream content preserved after StepCancelledEvent")
}
}
// TestStepCancelled_noStreamContent_noCmd verifies that StepCancelledEvent with
// no accumulated stream content produces a nil cmd (nothing to flush).
func TestStepCancelled_noStreamContent_noCmd(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.StepCancelledEvent{})
if cmd != nil {
t.Fatal("expected nil cmd on StepCancelledEvent with no stream content")
}
}
// TestStepCancelled_noErrorPrinted verifies that StepCancelledEvent does NOT
// produce an error message (unlike StepErrorEvent).
func TestStepCancelled_noErrorPrinted(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
// With no stream content, cmd should be nil (no flush, and no error print).
_, cmd := m.Update(app.StepCancelledEvent{})
if cmd != nil {
t.Fatal("expected nil cmd for StepCancelledEvent with no stream content — should not print error")
}
}
// --------------------------------------------------------------------------
// ESC cancel flow
// --------------------------------------------------------------------------
// TestESCCancel_singleTap verifies that the first ESC press during stateWorking
// sets canceling=true (and does not call CancelCurrentStep).
func TestESCCancel_singleTap(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, tea.KeyPressMsg{Code: tea.KeyEscape})
if !m.canceling {
t.Fatal("expected canceling=true after first ESC in stateWorking")
}
if ctrl.cancelCalled != 0 {
t.Fatalf("expected no CancelCurrentStep call after first ESC, got %d", ctrl.cancelCalled)
}
}
// TestESCCancel_doubleTap verifies that a second ESC press while canceling=true
// calls CancelCurrentStep() and resets canceling.
func TestESCCancel_doubleTap(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m.canceling = true // simulate first ESC already pressed
m = sendMsg(m, tea.KeyPressMsg{Code: tea.KeyEscape})
if m.canceling {
t.Fatal("expected canceling=false after second ESC")
}
if ctrl.cancelCalled != 1 {
t.Fatalf("expected CancelCurrentStep called once after double ESC, got %d", ctrl.cancelCalled)
}
}
// TestESCCancel_timerExpiry verifies that cancelTimerExpiredMsg resets canceling
// to false (timer fires before second ESC).
func TestESCCancel_timerExpiry(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m.canceling = true
m = sendMsg(m, core.CancelTimerExpiredMsg{})
if m.canceling {
t.Fatal("expected canceling=false after timer expiry")
}
if ctrl.cancelCalled != 0 {
t.Fatalf("expected no CancelCurrentStep after timer expiry, got %d", ctrl.cancelCalled)
}
}
// TestESCCancel_noEffectInStateInput verifies that ESC in stateInput does not set
// canceling (it's passed to child components instead).
func TestESCCancel_noEffectInStateInput(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// state is stateInput by default
m = sendMsg(m, tea.KeyPressMsg{Code: tea.KeyEscape})
if m.canceling {
t.Fatal("expected canceling=false when ESC pressed in stateInput")
}
if ctrl.cancelCalled != 0 {
t.Fatalf("expected no CancelCurrentStep in stateInput, got %d", ctrl.cancelCalled)
}
}
// TestESCCancel_clearedOnStepComplete verifies that completing a step clears
// any pending canceling state.
func TestESCCancel_clearedOnStepComplete(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m.canceling = true
m = sendMsg(m, app.StepCompleteEvent{ResponseText: "done"})
if m.canceling {
t.Fatal("expected canceling=false after StepCompleteEvent")
}
}
// --------------------------------------------------------------------------
// Queued messages
// --------------------------------------------------------------------------
// TestQueuedMessages_storedOnQueuedSubmit verifies that submitting a prompt
// while the agent is busy stores the text in queuedMessages (not scrollback).
func TestQueuedMessages_storedOnQueuedSubmit(t *testing.T) {
ctrl := &stubAppController{queueLen: 1} // simulate busy (will return 1)
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(core.SubmitMsg{Text: "queued prompt"})
if len(m.queuedMessages) != 1 {
t.Fatalf("expected 1 queued message, got %d", len(m.queuedMessages))
}
if m.queuedMessages[0] != "queued prompt" {
t.Fatalf("expected queued message text 'queued prompt', got %q", m.queuedMessages[0])
}
// Should NOT flush (message is anchored in ScrollList).
if cmd != nil {
t.Fatal("expected nil cmd for queued submit (message should not print to scrollback)")
}
}
// TestQueuedMessages_poppedOnQueueUpdated verifies that QueueUpdatedEvent pops
// consumed messages from queuedMessages and moves them to pendingUserPrints.
// The actual printing is deferred to SpinnerEvent{Show: true} to preserve
// chronological order with the preceding assistant response.
func TestQueuedMessages_poppedOnQueueUpdated(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.queuedMessages = []string{"first", "second", "third"}
// Simulate drainQueue popping one item (length goes from 3 to 2).
m = sendMsg(m, app.QueueUpdatedEvent{Length: 2})
if len(m.queuedMessages) != 2 {
t.Fatalf("expected 2 queued messages after pop, got %d", len(m.queuedMessages))
}
if m.queuedMessages[0] != "second" {
t.Fatalf("expected first remaining message 'second', got %q", m.queuedMessages[0])
}
// Popped message should be deferred to pendingUserPrints.
if len(m.pendingUserPrints) != 1 {
t.Fatalf("expected 1 pending user print, got %d", len(m.pendingUserPrints))
}
if m.pendingUserPrints[0] != "first" {
t.Fatalf("expected pending message 'first', got %q", m.pendingUserPrints[0])
}
}
// TestQueuedMessages_allPoppedOnDrain verifies that QueueUpdatedEvent with
// Length=0 pops all remaining queued messages into pendingUserPrints.
func TestQueuedMessages_allPoppedOnDrain(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.queuedMessages = []string{"alpha", "beta"}
m = sendMsg(m, app.QueueUpdatedEvent{Length: 0})
if len(m.queuedMessages) != 0 {
t.Fatalf("expected 0 queued messages after drain, got %d", len(m.queuedMessages))
}
if len(m.pendingUserPrints) != 2 {
t.Fatalf("expected 2 pending user prints, got %d", len(m.pendingUserPrints))
}
}
// --------------------------------------------------------------------------
// Window resize
// --------------------------------------------------------------------------
// TestWindowResize_updatesWidthHeight verifies that tea.WindowSizeMsg updates
// m.width and m.height.
func TestWindowResize_updatesWidthHeight(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m = sendMsg(m, tea.WindowSizeMsg{Width: 120, Height: 40})
if m.width != 120 {
t.Fatalf("expected width=120, got %d", m.width)
}
if m.height != 40 {
t.Fatalf("expected height=40, got %d", m.height)
}
}
// TestWindowResize_propagatesToStream verifies that tea.WindowSizeMsg is forwarded
// to the stream component.
func TestWindowResize_propagatesToStream(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
_ = sendMsg(m, tea.WindowSizeMsg{Width: 100, Height: 30})
if stream.lastMsg == nil {
t.Fatal("expected stream component to receive WindowSizeMsg")
}
if _, ok := stream.lastMsg.(tea.WindowSizeMsg); !ok {
t.Fatalf("expected stream.lastMsg to be WindowSizeMsg, got %T", stream.lastMsg)
}
}
// TestWindowResize_distributeHeight verifies that distributeHeight correctly
// sets the stream height after a resize.
func TestWindowResize_distributeHeight(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// With height=30, scroll height = 30 - 1 (separator) - 8 (input) - 1 (statusBar) = 20
m = sendMsg(m, tea.WindowSizeMsg{Width: 80, Height: 30})
_ = m
if m.scrollList.height != 20 {
t.Fatalf("expected scroll list height=20, got %d", m.scrollList.height)
}
}
// --------------------------------------------------------------------------
// Step complete behavior
// --------------------------------------------------------------------------
// TestStepComplete_preservesStreamContent verifies that StepCompleteEvent
// does NOT flush stream content — it stays visible for deferred flush.
func TestStepComplete_preservesStreamContent(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
// Simulate accumulated streaming text.
stream.renderedContent = "rendered assistant text"
_ = sendMsg(m, app.StepCompleteEvent{ResponseText: "final answer"})
if stream.renderedContent != "rendered assistant text" {
t.Fatal("expected stream content preserved after StepCompleteEvent")
}
}
// TestStepComplete_noStreamContent_noCmd verifies that StepCompleteEvent with
// no accumulated stream content produces a nil cmd (nothing to flush).
func TestStepComplete_noStreamContent_noCmd(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
_, cmd := m.Update(app.StepCompleteEvent{})
if cmd != nil {
t.Fatal("expected nil cmd on StepCompleteEvent with no stream content")
}
}
// TestSubmitMsg_printsUserMessage verifies that submitMsg adds the user message
// to the ScrollList messages and triggers a layout update.
func TestSubmitMsg_printsUserMessage(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m = sendMsg(m, core.SubmitMsg{Text: "user query"})
// In alt screen mode, user messages are added to the in-memory ScrollList
// rather than printed separately. Verify the message was added.
found := false
for _, msg := range m.messages {
if tm, ok := msg.(*TextMessageItem); ok && tm.role == "user" && tm.content == "user query" {
found = true
break
}
}
if !found {
t.Fatal("expected user message 'user query' in ScrollList messages")
}
}
// TestToolCallStarted_flushesOnly verifies that ToolCallStartedEvent marks
// any active StreamingMessageItem as complete and resets the stream.
func TestToolCallStarted_flushesOnly(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
// With no stream content, nothing should change.
initialCount := len(m.messages)
m = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
if len(m.messages) != initialCount {
t.Fatal("expected no new messages on ToolCallStartedEvent with no stream content")
}
// Simulate a StreamingMessageItem already in messages (as if appendStreamingChunk was called)
// plus the stream component having rendered content.
streamItem := NewStreamingMessageItem("stream-1", "assistant", "test-model")
streamItem.AppendChunk("partial text")
m.messages = append(m.messages, streamItem)
stream.renderedContent = "partial text"
_ = sendMsg(m, app.ToolCallStartedEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
})
// The StreamingMessageItem should have been marked complete.
if streamItem.streaming {
t.Fatal("expected StreamingMessageItem to be marked complete after ToolCallStartedEvent")
}
// Stream should have been reset.
if stream.resetCalled == 0 {
t.Fatal("expected stream.Reset() to be called")
}
}
// TestToolResult_printsAndStartsSpinner verifies that ToolResultEvent adds
// the tool result to the ScrollList and the stream receives a SpinnerEvent.
func TestToolResult_printsAndStartsSpinner(t *testing.T) {
ctrl := &stubAppController{}
m, stream, _ := newTestAppModel(ctrl)
m.state = stateWorking
initialCount := len(m.messages)
m = sendMsg(m, app.ToolResultEvent{
ToolName: "bash",
ToolArgs: "{}",
Result: "output",
IsError: false,
})
// Tool result should have been added to ScrollList messages.
if len(m.messages) <= initialCount {
t.Fatal("expected tool result message added to ScrollList")
}
// Stream should have received a SpinnerEvent to start spinner for next LLM call.
if stream.lastMsg == nil {
t.Fatal("expected stream to receive SpinnerEvent after ToolResultEvent")
}
if se, ok := stream.lastMsg.(app.SpinnerEvent); !ok || !se.Show {
t.Fatalf("expected SpinnerEvent{Show:true}, got %T", stream.lastMsg)
}
}
// TestToolOutputEvent_accumulatesBashOutput verifies that ToolOutputEvent
// accumulates stdout and stderr lines into a StreamingBashOutputItem in the ScrollList.
func TestToolOutputEvent_accumulatesBashOutput(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
// Send stdout chunk.
m = sendMsg(m, app.ToolOutputEvent{
ToolCallID: "call-1",
ToolName: "bash",
Chunk: "line one\n",
IsStderr: false,
})
// Should have created a StreamingBashOutputItem in messages.
var bashItem *StreamingBashOutputItem
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
break
}
}
if bashItem == nil {
t.Fatal("expected StreamingBashOutputItem in messages after ToolOutputEvent")
return
}
if len(bashItem.stdoutLines) != 1 || bashItem.stdoutLines[0] != "line one\n" {
t.Fatalf("expected stdout=['line one\\n'], got %v", bashItem.stdoutLines)
}
if len(bashItem.stderrLines) != 0 {
t.Fatalf("expected empty stderr, got %v", bashItem.stderrLines)
}
// Send another stdout chunk.
m = sendMsg(m, app.ToolOutputEvent{
ToolCallID: "call-1",
ToolName: "bash",
Chunk: "line two\n",
IsStderr: false,
})
// Re-find the bash item (same item, updated)
bashItem = nil
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
}
}
if bashItem == nil || len(bashItem.stdoutLines) != 2 {
t.Fatalf("expected 2 stdout lines, got %d", len(bashItem.stdoutLines))
}
// Send stderr chunk.
m = sendMsg(m, app.ToolOutputEvent{
ToolCallID: "call-1",
ToolName: "bash",
Chunk: "error: something failed\n",
IsStderr: true,
})
bashItem = nil
for _, msg := range m.messages {
if item, ok := msg.(*StreamingBashOutputItem); ok {
bashItem = item
}
}
if bashItem == nil || len(bashItem.stderrLines) != 1 {
t.Fatalf("expected 1 stderr line, got %d", len(bashItem.stderrLines))
}
if bashItem.stderrLines[0] != "error: something failed\n" {
t.Fatalf("expected stderr 'error: something failed\\n', got %q", bashItem.stderrLines[0])
}
}
// TestToolResult_clearsStreamingBashOutput verifies that ToolResultEvent clears
// the streaming bash output buffers since the final result will be printed.
func TestToolResult_clearsStreamingBashOutput(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
// Accumulate some bash output.
m.streamingBashOutput = []string{"output line"}
m.streamingBashStderr = []string{"error line"}
_, _ = m.Update(app.ToolResultEvent{
ToolName: "bash",
ToolArgs: `{"cmd":"ls"}`,
Result: "output line\nerror line\n",
IsError: false,
})
if len(m.streamingBashOutput) != 0 {
t.Fatalf("expected streamingBashOutput cleared, got %v", m.streamingBashOutput)
}
if len(m.streamingBashStderr) != 0 {
t.Fatalf("expected streamingBashStderr cleared, got %v", m.streamingBashStderr)
}
}
// TestToolCallStarted_extractsBashCommand verifies that ToolCallStartedEvent
// extracts the bash command from ToolArgs and stores it for the streaming output header.
func TestToolCallStarted_extractsBashCommand(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
// Send ToolCallStartedEvent with bash command.
m = sendMsg(m, app.ToolCallStartedEvent{
ToolCallID: "call-1",
ToolName: "bash",
ToolArgs: `{"command":"ls -la /home"}`,
})
if m.streamingBashCommand != "ls -la /home" {
t.Fatalf("expected streamingBashCommand='ls -la /home', got %q", m.streamingBashCommand)
}
// ToolResultEvent should clear the command.
m = sendMsg(m, app.ToolResultEvent{
ToolCallID: "call-1",
ToolName: "bash",
ToolArgs: `{"command":"ls -la /home"}`,
Result: "output",
IsError: false,
})
if m.streamingBashCommand != "" {
t.Fatalf("expected streamingBashCommand cleared, got %q", m.streamingBashCommand)
}
}
// TestToolCallStarted_nonBashTool_doesNotSetCommand verifies that non-bash tools
// do not set the streamingBashCommand field.
func TestToolCallStarted_nonBashTool_doesNotSetCommand(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
// Send ToolCallStartedEvent with a non-bash tool.
m = sendMsg(m, app.ToolCallStartedEvent{
ToolCallID: "call-1",
ToolName: "read",
ToolArgs: `{"file":"/etc/passwd"}`,
})
if m.streamingBashCommand != "" {
t.Fatalf("expected streamingBashCommand to remain empty for non-bash tools, got %q", m.streamingBashCommand)
}
}
// TestStepError_printCmd verifies that StepErrorEvent with a non-nil error
// adds an error message to the ScrollList.
func TestStepError_printCmd(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
initialCount := len(m.messages)
m = sendMsg(m, app.StepErrorEvent{Err: errors.New("agent failed")})
// Error should have been added to ScrollList messages.
if len(m.messages) <= initialCount {
t.Fatal("expected error message added to ScrollList on StepErrorEvent")
}
}
// --------------------------------------------------------------------------
// SpinnerEvent: stateWorking on Show=true
// --------------------------------------------------------------------------
// TestSpinnerEvent_showTransitionsToWorking verifies that SpinnerEvent{Show:true}
// transitions the model to stateWorking (important for queued step drain path).
func TestSpinnerEvent_showTransitionsToWorking(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// After a step completes, model is back in stateInput. The next queued step
// starts and fires SpinnerEvent{Show: true}.
m.state = stateInput
m = sendMsg(m, app.SpinnerEvent{Show: true})
if m.state != stateWorking {
t.Fatalf("expected stateWorking after SpinnerEvent{Show:true} in stateInput, got %v", m.state)
}
}
// TestSpinnerEvent_hidDoesNotTransitionState verifies that SpinnerEvent{Show:false}
// does not change the state.
func TestSpinnerEvent_hideDoesNotTransitionState(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, app.SpinnerEvent{Show: false})
if m.state != stateWorking {
t.Fatalf("expected state to remain stateWorking, got %v", m.state)
}
}
// --------------------------------------------------------------------------
// ctrl+c double-press to quit
// --------------------------------------------------------------------------
// TestCtrlC_producesQuit verifies that double ctrl+c returns a tea.Quit cmd.
func TestCtrlC_producesQuit(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// First Ctrl+C arms the quit flag.
updated, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
m = updated.(*AppModel)
if cmd == nil {
t.Fatal("expected a command after first ctrl+c, got nil")
}
// Should be a reset timer, not quit.
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg after first ctrl+c, got %T", msg)
}
// Second Ctrl+C should quit.
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("expected tea.Quit cmd on second ctrl+c, got nil")
}
msg = cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Fatalf("expected QuitMsg from second ctrl+c, got %T", msg)
}
}
// TestCtrlC_clearsInput_firstPress tests that Ctrl+C clears input on first
// press when there's content, and requires a second press to quit.
func TestCtrlC_clearsInput_firstPress(t *testing.T) {
// Create a real InputComponent to test the clear behavior
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// Replace with real InputComponent that has content
input := NewInputComponent(80, ctrl)
input.textarea.SetValue("some text content")
m.input = input
// First Ctrl+C should clear input, not quit
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
// Should have cleared the input
if input.textarea.Value() != "" {
t.Fatalf("expected input to be cleared, got %q", input.textarea.Value())
}
// Should have set ctrlCPressedOnce flag
if !m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
}
// The command should be a ctrlCResetCmd (not tea.Quit)
if cmd == nil {
t.Fatal("expected a command after first Ctrl+C, got nil")
}
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
}
// Second Ctrl+C should now quit
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("expected tea.Quit cmd on second Ctrl+C, got nil")
}
msg = cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Fatalf("expected QuitMsg on second Ctrl+C, got %T", msg)
}
}
// TestCtrlC_resetAfterSubmit tests that the Ctrl+C flag is reset after
// submitting a message, so the next Ctrl+C clears input again.
func TestCtrlC_resetAfterSubmit(t *testing.T) {
// Use newTestAppModel but replace the input with a real InputComponent
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// Replace with real InputComponent
input := NewInputComponent(80, ctrl)
input.textarea.SetValue("content")
m.input = input
// First Ctrl+C clears input
updated, _ := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
m = updated.(*AppModel)
if input.textarea.Value() != "" {
t.Fatal("expected input to be cleared")
}
// Flag should be set
if !m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
}
// Simulate CtrlCResetMsg being processed (timer expired)
updated, _ = m.Update(core.CtrlCResetMsg{})
m = updated.(*AppModel)
// Flag should be reset
if m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be false after CtrlCResetMsg")
}
// Add new content to input
input.textarea.SetValue("new content")
// Next Ctrl+C should clear again (not quit) because flag was reset
_, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if input.textarea.Value() != "" {
t.Fatalf("expected input to be cleared again, got %q", input.textarea.Value())
}
if cmd == nil {
t.Fatal("expected a command after Ctrl+C, got nil")
}
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
}
}
// TestCtrlC_emptyInput_armsQuit tests that Ctrl+C on empty input still
// requires a second press to quit (consistent double-press behavior).
func TestCtrlC_emptyInput_armsQuit(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
// Replace with real InputComponent (empty by default)
input := NewInputComponent(80, ctrl)
m.input = input
// First Ctrl+C on empty input should arm the flag, not quit.
updated, cmd := m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
m = updated.(*AppModel)
if !m.ctrlCPressedOnce {
t.Fatal("expected ctrlCPressedOnce to be true after first Ctrl+C")
}
if cmd == nil {
t.Fatal("expected a command (reset timer), got nil")
}
msg := cmd()
if _, ok := msg.(core.CtrlCResetMsg); !ok {
t.Fatalf("expected CtrlCResetMsg, got %T", msg)
}
// Second Ctrl+C should quit.
_, cmd = m.Update(tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl})
if cmd == nil {
t.Fatal("expected tea.Quit cmd on second Ctrl+C, got nil")
}
msg = cmd()
if _, ok := msg.(tea.QuitMsg); !ok {
t.Fatalf("expected QuitMsg on second Ctrl+C, got %T", msg)
}
}
// --------------------------------------------------------------------------
// submitMsg during stateWorking (queue path)
// --------------------------------------------------------------------------
// TestSubmit_duringWorking_stays verifies that submitting a new prompt while in
// stateWorking keeps the model in stateWorking (queued via app.Run).
func TestSubmit_duringWorking_stays(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.state = stateWorking
m = sendMsg(m, core.SubmitMsg{Text: "queued prompt"})
if m.state != stateWorking {
t.Fatalf("expected stateWorking to persist after submitMsg during working, got %v", m.state)
}
if len(ctrl.runCalls) != 1 || ctrl.runCalls[0] != "queued prompt" {
t.Fatalf("expected Run('queued prompt') called, got %v", ctrl.runCalls)
}
}
// --------------------------------------------------------------------------
// truncateMessageForBlock
// --------------------------------------------------------------------------
// TestTruncateMessageForBlock_shortMessage verifies that short messages are
// returned unchanged.
func TestTruncateMessageForBlock_shortMessage(t *testing.T) {
msg := "hello world"
got := truncateMessageForBlock(msg, 3, 80)
if got != msg {
t.Fatalf("expected unchanged message, got %q", got)
}
}
// TestTruncateMessageForBlock_exactLines verifies that a message with exactly
// maxLines hard lines is returned unchanged.
func TestTruncateMessageForBlock_exactLines(t *testing.T) {
msg := "line1\nline2\nline3"
got := truncateMessageForBlock(msg, 3, 80)
if got != msg {
t.Fatalf("expected unchanged message, got %q", got)
}
}
// TestTruncateMessageForBlock_tooManyLines verifies that messages exceeding
// maxLines are truncated with an ellipsis.
func TestTruncateMessageForBlock_tooManyLines(t *testing.T) {
msg := "line1\nline2\nline3\nline4\nline5"
got := truncateMessageForBlock(msg, 3, 80)
want := "line1\nline2\nline3…"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
// TestTruncateMessageForBlock_longWrappingLine verifies that a single long
// line that would wrap beyond maxLines is truncated.
func TestTruncateMessageForBlock_longWrappingLine(t *testing.T) {
// 100 chars at width 20 = 5 visual lines, exceeds maxLines=3
msg := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
got := truncateMessageForBlock(msg, 3, 20)
// Should be truncated to 3*20=60 runes + "…"
if len([]rune(got)) != 61 { // 60 runes + "…"
t.Fatalf("expected 61 runes (60 + ellipsis), got %d runes: %q", len([]rune(got)), got)
}
if got[len(got)-3:] != "…" { // "…" is 3 bytes in UTF-8
t.Fatal("expected trailing ellipsis")
}
}
// TestTruncateMessageForBlock_emptyMessage verifies that empty messages are
// returned unchanged.
func TestTruncateMessageForBlock_emptyMessage(t *testing.T) {
got := truncateMessageForBlock("", 3, 80)
if got != "" {
t.Fatalf("expected empty string, got %q", got)
}
}
// TestTruncateMessageForBlock_mixedWrapAndHardLines verifies truncation when
// some hard lines wrap and the total exceeds maxLines.
func TestTruncateMessageForBlock_mixedWrapAndHardLines(t *testing.T) {
// First line: 40 chars at width 20 = 2 visual lines
// Second line: "short" = 1 visual line (total: 3, exactly at limit)
// Third line: would exceed
msg := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nshort\nextra"
got := truncateMessageForBlock(msg, 3, 20)
want := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\nshort…"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
// TestRenderQueuedMessages_truncatesLongMessages verifies that the rendered
// queued message view truncates long messages instead of showing them in full.
func TestRenderQueuedMessages_truncatesLongMessages(t *testing.T) {
ctrl := &stubAppController{}
m, _, _ := newTestAppModel(ctrl)
m.width = 80
// Queue a very long message (20 lines).
var b strings.Builder
for i := range 20 {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString("This is a long line of text for testing purposes")
}
m.queuedMessages = []string{b.String()}
rendered := m.renderQueuedMessages()
if rendered == "" {
t.Fatal("expected non-empty rendered output")
}
// The full message would be ~20+ lines. With truncation to 3 content
// lines + badge + padding, it should be much shorter.
lines := len(strings.Split(rendered, "\n"))
// 3 content lines + 1 badge + 2 padding + border overhead ≈ ~7 lines max
if lines > 10 {
t.Fatalf("expected truncated output to be ≤10 lines, got %d lines", lines)
}
}