diff --git a/internal/ui/model.go b/internal/ui/model.go index f8fa53bf..267450f2 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -564,13 +564,14 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case app.SpinnerEvent: // SpinnerEvent{Show: true} means a new agent step has started (either - // freshly or from the queue after a previous step completed). Transition - // to stateWorking so the TUI reflects the active state. This is - // especially important for queued prompts: after StepCompleteEvent - // resets state to stateInput, the next queued step fires SpinnerEvent - // and we must go back to stateWorking. + // freshly or from the queue after a previous step completed). Flush + // any leftover stream content from the previous step to scrollback + // before starting the new one. This deferred flush avoids shrinking + // the view at step-completion time (which leaves blank lines). if msg.Show { + cmds = append(cmds, m.flushStreamContent()) m.state = stateWorking + m.distributeHeight() } if m.stream != nil { _, cmd := m.stream.Update(msg) @@ -636,41 +637,42 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.distributeHeight() case app.StepCompleteEvent: - // Flush any remaining streamed text to scrollback, then reset stream - // and return to input state. Token usage is rendered in the status - // bar — the app layer has already updated the shared UsageTracker - // before sending this event. - cmds = append(cmds, m.flushStreamContent()) + // Keep stream content visible in the view — don't flush to scrollback + // yet. Flushing + resetting in the same frame would shrink the view + // height, and bubbletea's inline renderer leaves blank lines at the + // bottom for the orphaned rows. The content will be flushed to + // scrollback when the next step starts (SpinnerEvent{Show: true}). + // Just stop the spinner and return to input state. if m.stream != nil { - m.stream.Reset() + _, cmd := m.stream.Update(app.SpinnerEvent{Show: false}) + cmds = append(cmds, cmd) } m.state = stateInput m.canceling = false - m.distributeHeight() case app.StepCancelledEvent: - // User cancelled the step (double-ESC). Flush any partial content, - // cut off the response where it was, and return to input with no error. - cmds = append(cmds, m.flushStreamContent()) + // User cancelled the step (double-ESC). Keep partial stream content + // visible (same reasoning as StepCompleteEvent). Just stop the spinner. if m.stream != nil { - m.stream.Reset() + _, cmd := m.stream.Update(app.SpinnerEvent{Show: false}) + cmds = append(cmds, cmd) } m.state = stateInput m.canceling = false - m.distributeHeight() case app.StepErrorEvent: - // Flush streamed text, print the error, reset stream, return to input. - cmds = append(cmds, m.flushStreamContent()) + // Keep partial stream content visible (same reasoning as + // StepCompleteEvent). Print the error to scrollback — it appears + // above the view, and the partial response stays visible below. + if m.stream != nil { + _, cmd := m.stream.Update(app.SpinnerEvent{Show: false}) + cmds = append(cmds, cmd) + } if msg.Err != nil { cmds = append(cmds, m.printErrorResponse(msg)) } - if m.stream != nil { - m.stream.Reset() - } m.state = stateInput m.canceling = false - m.distributeHeight() case app.CompactCompleteEvent: if m.stream != nil { diff --git a/internal/ui/model_test.go b/internal/ui/model_test.go index d8f8c1be..c448078b 100644 --- a/internal/ui/model_test.go +++ b/internal/ui/model_test.go @@ -145,7 +145,8 @@ func TestStateTransition_InputToWorking(t *testing.T) { } // TestStateTransition_WorkingToInput_StepComplete verifies that StepCompleteEvent -// transitions from stateWorking back to stateInput and resets the stream component. +// 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) @@ -156,13 +157,14 @@ func TestStateTransition_WorkingToInput_StepComplete(t *testing.T) { if m.state != stateInput { t.Fatalf("expected stateInput after StepCompleteEvent, got %v", m.state) } - if stream.resetCalled != 1 { - t.Fatalf("expected stream.Reset() called once, got %d", stream.resetCalled) + 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 resets the stream component. +// 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) @@ -173,8 +175,8 @@ func TestStateTransition_WorkingToInput_StepError(t *testing.T) { if m.state != stateInput { t.Fatalf("expected stateInput after StepErrorEvent, got %v", m.state) } - if stream.resetCalled != 1 { - t.Fatalf("expected stream.Reset() called once, got %d", stream.resetCalled) + if stream.resetCalled != 0 { + t.Fatalf("expected stream NOT reset on StepErrorEvent (deferred flush), got %d resets", stream.resetCalled) } } @@ -197,7 +199,8 @@ func TestStepError_nilErr(t *testing.T) { // -------------------------------------------------------------------------- // TestStateTransition_WorkingToInput_StepCancelled verifies that StepCancelledEvent -// transitions from stateWorking back to stateInput and resets the stream component. +// 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) @@ -208,8 +211,8 @@ func TestStateTransition_WorkingToInput_StepCancelled(t *testing.T) { if m.state != stateInput { t.Fatalf("expected stateInput after StepCancelledEvent, got %v", m.state) } - if stream.resetCalled != 1 { - t.Fatalf("expected stream.Reset() called once, got %d", stream.resetCalled) + if stream.resetCalled != 0 { + t.Fatalf("expected stream NOT reset on StepCancelledEvent (deferred flush), got %d resets", stream.resetCalled) } } @@ -228,18 +231,18 @@ func TestStepCancelled_clearsCanceling(t *testing.T) { } } -// TestStepCancelled_flushesStreamContent verifies that StepCancelledEvent -// flushes accumulated stream content via tea.Println (non-nil cmd). -func TestStepCancelled_flushesStreamContent(t *testing.T) { +// 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" - _, cmd := m.Update(app.StepCancelledEvent{}) + _ = sendMsg(m, app.StepCancelledEvent{}) - if cmd == nil { - t.Fatal("expected non-nil cmd (tea.Println) on StepCancelledEvent with stream content") + if stream.renderedContent != "partial assistant response" { + t.Fatal("expected stream content preserved after StepCancelledEvent") } } @@ -477,20 +480,19 @@ func TestWindowResize_distributeHeight(t *testing.T) { // tea.Println on step complete // -------------------------------------------------------------------------- -// TestStepComplete_flushesStreamContent verifies that StepCompleteEvent -// flushes accumulated stream content via tea.Println (non-nil cmd). -func TestStepComplete_flushesStreamContent(t *testing.T) { +// 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" - _, cmd := m.Update(app.StepCompleteEvent{ResponseText: "final answer"}) + _ = sendMsg(m, app.StepCompleteEvent{ResponseText: "final answer"}) - // A non-nil cmd means flushStreamContent returned tea.Println(...) - if cmd == nil { - t.Fatal("expected non-nil cmd (tea.Println) on StepCompleteEvent with stream content") + if stream.renderedContent != "rendered assistant text" { + t.Fatal("expected stream content preserved after StepCompleteEvent") } }