diff --git a/internal/tui/model.go b/internal/tui/model.go index 280c8ccfb..2bb950de0 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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. @@ -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 } @@ -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 != "" { @@ -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 @@ -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 @@ -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 @@ -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 @@ -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: diff --git a/internal/tui/stream_clear_test.go b/internal/tui/stream_clear_test.go new file mode 100644 index 000000000..6732c154a --- /dev/null +++ b/internal/tui/stream_clear_test.go @@ -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() +} + +// 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") + } +}