Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 100 additions & 4 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,20 @@ type model struct {
lastStreamActivity time.Time
fadeActive bool
fadeDisabled bool // streaming fade off (ZERO_NO_FADE / SSH / tmux / low-color / reduced motion)
reducedMotion bool // ZERO_REDUCED_MOTION / no-TTY: static spinner glyph, no fade
// streamClearDisabled turns off the full-redraw-on-streamed-newline
// workaround for terminals that render scroll regions correctly
// (ZERO_NO_STREAM_CLEAR=1). lastStreamClear rate-limits the redraws the
// workaround schedules so heavy streaming output (code, logs, diffs)
// coalesces to a bounded number of repaints per second instead of one
// per newline. pendingStreamClear tracks a newline that arrived while
// throttled: the redraw it would have triggered is deferred (flushed by
// a scheduled streamClearFlushMsg, or at stream end) instead of dropped
// outright, so a throttled newline that happens to be the last one of
// the turn still gets its caret repaired.
streamClearDisabled bool
lastStreamClear time.Time
pendingStreamClear bool
reducedMotion bool // ZERO_REDUCED_MOTION / no-TTY: static spinner glyph, no fade
// In-progress tool call whose arguments are streaming (a file being written),
// shown live by streamingToolCallView so a long write/edit isn't a frozen
// spinner. Cleared when the call completes (next text/turn) — see updateModel.
Expand Down Expand Up @@ -497,6 +510,28 @@ type agentTextMsg struct {
delta string
}

// streamClearThrottle is the minimum gap between full-screen stream-clear
// redraws. Newlines that arrive inside this window mark a deferred clear
// (pendingStreamClear) instead of firing immediately, so heavy streaming
// output coalesces to ~10 repaints/second while still guaranteeing a
// eventual caret repair.
const streamClearThrottle = 100 * time.Millisecond

// streamClearFlushMsg fires once, roughly when the stream-clear throttle
// window (see lastStreamClear) has elapsed, to flush a ClearScreen that a
// throttled newline deferred rather than fired directly. It's a no-op if
// nothing is pending by the time it lands (the common case, since most
// throttled newlines are followed by another one that flushes them first).
type streamClearFlushMsg struct{}

// scheduleStreamClearFlush returns a one-shot command that delivers a
// streamClearFlushMsg after d. Used to guarantee a deferred stream-clear
// redraw is eventually flushed even if no later newline or stream-end event
// does it first (see the streamClearFlushMsg case in Update).
func scheduleStreamClearFlush(d time.Duration) tea.Cmd {
return tea.Tick(d, func(time.Time) tea.Msg { return streamClearFlushMsg{} })
}

type exitConfirmExpiredMsg struct {
seq int
}
Expand Down Expand Up @@ -887,6 +922,12 @@ func newModel(ctx context.Context, options Options) model {
// Streaming text always renders statically at base ink (the disabled path in
// styleStreamingLine), so no accent glow and no per-line fade ticks.
m.fadeDisabled = true
// Terminals that handle scroll regions correctly can opt back into the
// fast incremental path; the redraw workaround (see the ClearScreen
// scheduling in updateModel) is otherwise on, rate-limited.
if v := strings.TrimSpace(os.Getenv("ZERO_NO_STREAM_CLEAR")); v != "" && v != "0" && !strings.EqualFold(v, "false") {
m.streamClearDisabled = true
}
// One session-long LSP manager (cheap to build — servers start lazily on the
// first Check), reused across prompts so gopls stays warm between turns.
if cwd != "" {
Expand Down Expand Up @@ -1984,6 +2025,37 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
// re-stamps the in-progress last entry so the line that's still
// being filled stays visibly fresh.
m.recordStreamingDelta(msg.delta)
var cmds []tea.Cmd
// The streaming caret (appendStreamingCursor) is appended to whatever
// visual line is currently last. Some terminal/renderer combinations
// (observed over multipass + Windows Terminal) fail to clear the
// caret's old cell when a newline moves it to a new line, leaving
// ghost carets behind. A newline is exactly the moment that risk
// exists, so force one full-screen redraw right then rather than
// leaving it to the incremental diff. Rate-limited: heavy streaming
// output (code, logs, diffs) would otherwise turn every coalesced
// newline into a full-screen repaint, a real throughput/latency cost
// on SSH and slow links. ~10 redraws/second is enough to keep the
// caret clean without dominating the write path; terminals that
// render scroll regions correctly can opt out entirely with
// ZERO_NO_STREAM_CLEAR=1. A newline that arrives inside the throttle
// window still owes a repair — it's marked pending and a one-shot
// timer is scheduled to flush it (see streamClearFlushMsg), instead
// of being dropped outright. That covers a throttled newline that
// turns out to be the turn's last one (agentResponseMsg also flushes
// any still-pending clear at stream end, belt-and-suspenders) as
// well as one buried in the middle of a long, still-streaming turn.
if strings.Contains(msg.delta, "\n") && !m.streamClearDisabled {
now := m.now()
if elapsed := now.Sub(m.lastStreamClear); elapsed >= streamClearThrottle {
m.lastStreamClear = now
m.pendingStreamClear = false
cmds = append(cmds, tea.ClearScreen)
} else if !m.pendingStreamClear {
m.pendingStreamClear = true
cmds = append(cmds, scheduleStreamClearFlush(streamClearThrottle-elapsed))
}
}
// The fade's tick is self-perpetuating (the streamingFadeTickMsg
// case schedules the next one). Schedule the FIRST tick only on
// the inactive→active transition; subsequent deltas just refresh
Expand All @@ -1995,10 +2067,10 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
startTick := !m.fadeActive
m.fadeActive = true
if startTick {
return m, streamingFadeTick()
cmds = append(cmds, streamingFadeTick())
}
}
return m, nil
return m, tea.Batch(cmds...)
case agentReasoningMsg:
if msg.runID != m.activeRunID {
return m, nil
Expand Down Expand Up @@ -2072,6 +2144,21 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
return m, streamingFadeTick()
case streamClearFlushMsg:
// Flush a newline-triggered redraw that the stream-clear throttle
// deferred (see agentTextMsg) once its window has elapsed. This runs
// independent of the streaming fade (which is unconditionally off —
// fadeDisabled is hardcoded true in newModel — so its tick can't be
// relied on to drive this), and independent of stream end: a turn
// that keeps streaming for a while after the throttled newline would
// otherwise leave the ghost caret up for the rest of the turn.
now := m.now()
if m.pendingStreamClear && now.Sub(m.lastStreamClear) >= streamClearThrottle {
m.lastStreamClear = now
m.pendingStreamClear = false
return m, tea.ClearScreen
}
return m, nil
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
Expand Down Expand Up @@ -2224,6 +2311,15 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m.clearStreamingToolCall() // active run finished — drop any lingering "writing" block
m.pending = false
m = m.disarmCancelConfirmation() // the run finished on its own — nothing left to confirm cancelling
// A newline-triggered redraw deferred by the stream-clear throttle
// (see agentTextMsg) may never get a later newline or fade tick to
// flush it if this was the turn's last delta — flush it here so the
// ghost caret isn't left behind at stream end.
var pendingClearCmd tea.Cmd
if m.pendingStreamClear {
m.pendingStreamClear = false
pendingClearCmd = tea.ClearScreen
}
// Fully reset the fade state at stream end. The next render
// emits the final row in solid ink (no settling animation), and
// the pending streamingFadeTickMsg that lands after this point
Expand Down Expand Up @@ -2365,7 +2461,7 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
m, loopTickCmd = m.ensureLoopTick()
}
next, queuedCmd := m.launchQueuedMessageIfReady()
return next, tea.Batch(titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
return next, tea.Batch(pendingClearCmd, titleCmd, recapCmd, sweepCmd, queuedCmd, loopTickCmd)
case sessionTitleGeneratedMsg:
return m.handleSessionTitleGenerated(msg)
case recapGeneratedMsg:
Expand Down
215 changes: 215 additions & 0 deletions internal/tui/stream_clear_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
package tui

import (
"reflect"
"testing"
"time"

tea "charm.land/bubbletea/v2"
)

// isClearScreenCmd reports whether c is the tea.ClearScreen command by
// function-pointer identity. That avoids evaluating the command (and any
// sibling commands in a batch) just to detect a clear.
func isClearScreenCmd(c tea.Cmd) bool {
return c != nil && reflect.ValueOf(c).Pointer() == reflect.ValueOf(tea.ClearScreen).Pointer()
}

// cmdIncludesClearScreen looks for a tea.ClearScreen among cmd and, if cmd
// is a tea.Batch wrapper, among its children. Batch wrappers return a
// BatchMsg without running their children; Tick (and similar) commands block,
// so those are only expanded with a short timeout and treated as "not a
// clear" if they don't return promptly. Nil commands are "not found".
func cmdIncludesClearScreen(cmd tea.Cmd) bool {
if cmd == nil {
return false
}
if isClearScreenCmd(cmd) {
return true
}
// Expand batches (and any other immediately-returning cmds). Bound the
// wait so a deferred stream-clear Tick is never a multi-100ms sleep.
ch := make(chan tea.Msg, 1)
go func() { ch <- cmd() }()
select {
case msg := <-ch:
return msgIncludesClearScreen(msg)
case <-time.After(20 * time.Millisecond):
return false
}
}

func msgIncludesClearScreen(msg tea.Msg) bool {
if msg == nil {
return false
}
if batch, ok := msg.(tea.BatchMsg); ok {
for _, c := range batch {
if cmdIncludesClearScreen(c) {
return true
}
}
return false
}
// clearScreenMsg is unexported; match by value equality with ClearScreen().
return msg == tea.ClearScreen()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// withFrozenClock pins m.now to t0 for deterministic throttle math.
func withFrozenClock(m *model, t0 time.Time) {
m.now = func() time.Time { return t0 }
}

// TestStreamClearThrottledNewlineIsDeferredNotDropped guards against a
// regression in the multipass/Windows-Terminal ghost-caret workaround: a
// newline arriving inside the stream-clear throttle window used to have its
// redraw silently dropped. If that throttled newline turned out to be the
// turn's last delta, nothing later would ever repair the ghost caret. The
// throttled newline must instead be remembered and flushed, here at stream
// end, rather than discarded.
func TestStreamClearThrottledNewlineIsDeferredNotDropped(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4.1"})
m = m.beginRun(nil)
rid := m.activeRunID
t0 := time.Unix(1_700_000_000, 0)
withFrozenClock(&m, t0)

// lastStreamClear is still the zero Time here, so it's far outside the
// throttle window and the first newline fires an immediate ClearScreen.
updated, cmd := m.Update(agentTextMsg{runID: rid, delta: "first line\n"})
m = updated.(model)
if !cmdIncludesClearScreen(cmd) {
t.Fatal("first newline after a long gap should fire an immediate ClearScreen")
}
if m.pendingStreamClear {
t.Fatal("an immediate clear should not also leave a clear pending")
}
if !m.lastStreamClear.Equal(t0) {
t.Fatalf("lastStreamClear should record m.now()=%v, got %v", t0, m.lastStreamClear)
}

// A second newline lands inside the 100ms throttle window: it must not
// fire its own ClearScreen, but it must be remembered as owed.
updated, cmd = m.Update(agentTextMsg{runID: rid, delta: "second line\n"})
m = updated.(model)
if cmdIncludesClearScreen(cmd) {
t.Fatal("a throttled newline should not fire its own ClearScreen")
}
if !m.pendingStreamClear {
t.Fatal("a throttled newline must mark a clear as pending instead of dropping it")
}

// This was, in fact, the turn's last delta — stream end must flush the
// pending clear rather than losing it.
updated, cmd = m.Update(agentResponseMsg{runID: rid})
m = updated.(model)
if !cmdIncludesClearScreen(cmd) {
t.Fatal("stream end must flush a pending stream-clear rather than dropping it")
}
if m.pendingStreamClear {
t.Fatal("stream end should clear the pending flag once it's flushed")
}
}

// TestStreamClearTwoNewlinesWithinThrottleWindow is the explicit two-newline
// regression for the rate-limit path: both deltas arrive inside the same
// streamClearThrottle window after an initial clear, so neither should fire
// an immediate ClearScreen, and the deferred pending flag must stay set
// (coalesced) until a later flush.
func TestStreamClearTwoNewlinesWithinThrottleWindow(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4.1"})
m = m.beginRun(nil)
rid := m.activeRunID
t0 := time.Unix(1_700_000_000, 0)
withFrozenClock(&m, t0)

updated, cmd := m.Update(agentTextMsg{runID: rid, delta: "line one\n"})
m = updated.(model)
if !cmdIncludesClearScreen(cmd) {
t.Fatal("setup: first newline should clear immediately")
}

// Stay frozen at t0 so both follow-ups fall inside the throttle window.
updated, cmd = m.Update(agentTextMsg{runID: rid, delta: "line two\n"})
m = updated.(model)
if cmdIncludesClearScreen(cmd) {
t.Fatal("second newline inside the window must not ClearScreen immediately")
}
if !m.pendingStreamClear {
t.Fatal("second newline inside the window must set pendingStreamClear")
}

// A third newline still inside the window must coalesce onto the same
// pending clear rather than dropping it or firing early.
updated, cmd = m.Update(agentTextMsg{runID: rid, delta: "line three\n"})
m = updated.(model)
if cmdIncludesClearScreen(cmd) {
t.Fatal("third newline inside the window must not ClearScreen immediately")
}
if !m.pendingStreamClear {
t.Fatal("pendingStreamClear must remain set across coalesced newlines")
}

// Advance past the throttle window and let the scheduled flush repair it.
withFrozenClock(&m, t0.Add(streamClearThrottle))
updated, cmd = m.Update(streamClearFlushMsg{})
m = updated.(model)
if !cmdIncludesClearScreen(cmd) {
t.Fatal("flush after the window must fire ClearScreen for coalesced newlines")
}
if m.pendingStreamClear {
t.Fatal("flush should clear the pending flag")
}
}

// TestStreamClearScheduledFlushRepairsGhostCaretMidStream guards the
// mid-stream flush path: once the throttle window has elapsed, the one-shot
// timer scheduled alongside the pending clear (see scheduleStreamClearFlush)
// repairs a throttled newline's redraw instead of waiting for stream end,
// which may be much later for a long-running turn. This has to be
// independent of the streaming-text fade tick: fadeDisabled is hardcoded true
// in newModel, so that ticker never actually runs.
func TestStreamClearScheduledFlushRepairsGhostCaretMidStream(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4.1"})
m = m.beginRun(nil)
rid := m.activeRunID
t0 := time.Unix(1_700_000_000, 0)
withFrozenClock(&m, t0)

updated, _ := m.Update(agentTextMsg{runID: rid, delta: "first line\n"})
m = updated.(model)
updated, _ = m.Update(agentTextMsg{runID: rid, delta: "second line\n"})
m = updated.(model)
if !m.pendingStreamClear {
t.Fatal("setup: expected the second newline to be throttled and pending")
}

// Move past the throttle window via the mockable clock, without a sleep.
withFrozenClock(&m, t0.Add(time.Second))

updated, cmd := m.Update(streamClearFlushMsg{})
m = updated.(model)
if !cmdIncludesClearScreen(cmd) {
t.Fatal("the scheduled flush should fire ClearScreen once the throttle window has elapsed")
}
if m.pendingStreamClear {
t.Fatal("the scheduled flush should clear the pending flag once it's flushed")
}
}

// TestStreamClearScheduledFlushNoopsWhenNothingPending guards against a stale
// timer re-firing a ClearScreen after its pending clear was already flushed
// by something else (e.g. stream end), or when nothing was ever pending.
func TestStreamClearScheduledFlushNoopsWhenNothingPending(t *testing.T) {
m := newModel(t.Context(), Options{ModelName: "gpt-4.1"})
m = m.beginRun(nil)

updated, cmd := m.Update(streamClearFlushMsg{})
m = updated.(model)
if cmd != nil {
t.Fatal("a flush with nothing pending should be a no-op")
}
if m.pendingStreamClear {
t.Fatal("a no-op flush should not set the pending flag")
}
}
Loading