fix: defer stream flush to prevent blank lines at bottom of TUI

On step completion, keep stream content visible in the view instead of
immediately flushing to scrollback and resetting. The flush+reset in the
same frame shrinks the view height, and bubbletea's inline renderer
leaves orphaned blank lines at the bottom for the rows it no longer
manages.

The deferred flush happens when the next step starts (SpinnerEvent),
right before new content begins streaming.
This commit is contained in:
Ed Zynda
2026-02-27 18:22:05 +03:00
parent d6f8020554
commit 6d18ded8f9
2 changed files with 49 additions and 45 deletions
+25 -23
View File
@@ -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 {
+24 -22
View File
@@ -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")
}
}