mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
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:
+25
-23
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user