Files
zero/internal/tui/stream_clear_test.go
euxaristiaandClaude Fable 5 5bb1bc11c3 fix(tui): fix rendering corruption over multipass + Windows Terminal (#709)
* fix(tui): disable bubbletea hard scroll optimization unconditionally

Vendors a patched charm.land/bubbletea/v2 (via a local go.mod replace)
with hard scroll optimization (using terminal scroll-region sequences
to shift existing lines instead of repainting) disabled unconditionally,
instead of only on GOOS=="windows".

That GOOS check only catches a Windows terminal driving the program
directly. It misses a Linux process running inside a multipass VM
whose output is ultimately displayed by Windows Terminal over a
remote shell, which corrupts rendering badly (garbled/overlapping
text, worse on resize) the same way the check exists to prevent.
There's no reliable way to detect that remote hop from inside the VM,
so scroll optimization is now off in all cases.

* fix(tui): force redraw on streamed newlines to clear stale caret

The streaming caret (appendStreamingCursor) is appended to whichever
visual line is currently last. Over a remote terminal chain
(multipass VM output displayed by Windows Terminal), the renderer's
incremental cell diff sometimes fails to clear the caret's old cell
when a newline moves it to a new line, leaving ghost carets behind.

Fire tea.ClearScreen when a streamed delta contains a newline: that's
exactly the moment the caret moves and the risk exists, so it forces
a full redraw right then instead of leaving it to the incremental
diff, without doing so on every animation tick.

* chore(tui): trim upstream repo metadata from vendored bubbletea fork

The vendored bubbletea-v2-patched module is ~99% byte-identical to
upstream charm.land/bubbletea/v2 v2.0.8; only cursed_renderer.go
differs (one line disabling hard scroll optimization). The .github
workflows, README.md, UPGRADE_GUIDE_V2.md, Taskfile.yaml and CI config
files are upstream repo metadata that serve no purpose inside this
repo, so remove them to slim the PR. The Go source and testdata golden
files are kept because Go's replace directive requires the full module
to be present and buildable.

Refs #709

* fix(tui): address review round on the rendering workaround and vendored fork

Fork correctness fixes (all flagged by review):

- key.go: KeyMediaRecord repeated the previous const expression and
  decoded record presses as previous-track; map it to uv.KeyMediaRecord.
- mouse.go: MouseButton11 likewise collapsed onto MouseButton10.
- termios_unix.go: BSDLY/BS0 are output-delay bits; read them from Oflag
  instead of Lflag so useBackspace is set correctly.
- cursed_renderer.go: count wrapped rows with (lineWidth-1)/w so an
  exact terminal-width multiple no longer inserts a blank line; compare
  colors by normalized RGBA via a nil-safe helper instead of interface
  equality (which panics on non-comparable implementations); and make
  clearScreen invalidate lastView so the queued erase flushes with the
  next frame instead of deferring until an unrelated view change — the
  reordering that could leave the ghost caret in place.
- exec_test.go: implement reset() on the spy renderer and inject it
  before Run; assert exec preserves renderer state (releaseTerminal
  with reset=false).
- logging_test.go: restore the default logger's output/prefix/flags and
  fail fast on setup errors.
- tea_test.go: tie the Quit-pumping goroutine to a channel closed when
  Run returns instead of leaking it until process exit.

TUI scoping (review P2): the forced redraw on streamed newlines is now
rate-limited to ~10/s so heavy streaming output no longer turns every
newline into a full-screen repaint, and ZERO_NO_STREAM_CLEAR=1 opts a
correctly-rendering terminal back into the fast incremental path.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tui): guarantee deferred stream-clear redraw and other review fixes

A throttled stream-clear could drop the redraw entirely when a
newline arrived in the last delta of a turn, leaving a ghost caret on
screen. Add a one-shot deferred flush timer plus a flush on stream
end. Fix a stale comment and a test that leaked global logger state
in the vendored bubbletea fork.

* fix(tui): use m.now for stream-clear throttle and harden tests

Drive stream-clear rate limiting through the mockable clock and a
shared streamClearThrottle constant so both the agentTextMsg and
flush paths stay in sync. Add an explicit two-newline-within-window
regression and detect ClearScreen by function pointer so tests do not
evaluate Tick side effects.

* fix(tui): relocate bubbletea fork out of third_party

Move the maintained bubbletea/v2 hard-scroll patch from
third_party/bubbletea-v2-patched to patches/bubbletea-v2 and update the
root go.mod replace. third_party/ is immutable vendored content per
AGENTS.md; a maintained fork belongs outside that tree.

* fix(ci,docs): run vendored bubbletea fork tests and document unsupported go install path

patches/bubbletea-v2 is its own Go module so `go test ./...` from the repo
root never touched its 7 test files. Add a CI step (and a make test-patches
target for local use) that runs the fork's own suite across the smoke
matrix.

The root go.mod's local `replace charm.land/bubbletea/v2 =>
./patches/bubbletea-v2` also breaks `go install
github.com/Gitlawb/zero/cmd/zero@<version>`, since a versioned install can't
resolve a replace pointing at a filesystem path. Document that path as
unsupported in README.md and docs/INSTALL.md and point at git clone + go
build instead, per jatmn's review findings on PR #709.

* ci: run vendored bubbletea fork tests with -race

CodeRabbit review on this PR. The fork exists specifically to patch a
concurrency bug (charmbracelet/bubbletea#1690), so its regression test
needs the race detector to actually catch a reintroduced race instead
of just passing on a benign-looking run.

* fix(tui): drop vendored bubbletea fork; keep stream-clear workaround

Maintainers asked not to ship an in-tree bubbletea replace: it breaks
versioned go install and is a large maintenance surface for a narrow
multipass/Windows Terminal scroll bug. Remove patches/bubbletea-v2 and
the replace directive; keep the model.go full-redraw-on-streamed-newline
workaround (ZERO_NO_STREAM_CLEAR to opt out). Restore normal install docs.

Refs Gitlawb/zero#709

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-24 08:48:16 +08:00

216 lines
7.8 KiB
Go

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")
}
}