mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* 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]>
216 lines
7.8 KiB
Go
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")
|
|
}
|
|
}
|