mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-08 01:09:46 -10:00
feat(tui): age-based fade for streaming assistant text (#223)
* feat(tui): age-based fade for streaming assistant text
While the assistant reply streams in, the latest line appears in the
brand lime (\#caff3f) and gradually settles to the standard off-white
ink over ~1.2 seconds, line by line. The effect mirrors the lime
spinner glyph in the liveness block: a quiet visual signal that the
model is still emitting, not a stylized neon glow.
Design choices (per line, not per rune):
* 12-step palette interpolated in linear sRGB from colorAccent to
colorInk, pre-computed at init() so per-frame lookups are a
struct-field access, not a hex parse.
* 1.2s fade duration; 150ms tick (independent of the 80ms spinner).
* Hard stop at stream end: the agentResponseMsg handler flips
fadeActive = false and the next streamingFadeTickMsg short-circuits.
* Reset on terminal resize (visual line count changes) and on cancel.
* The last visual line uses lastStreamActivity (always freshest) so
the user can see exactly where the model is currently typing.
Test fixtures that pre-populate m.streamingText without going through
the agentTextMsg branch keep rendering identically to before — the
fade code is defensive when lineAges is nil.
The spec-impl run (which calls runAgent directly, bypassing the
normal launchPrompt path) seeds the fade state explicitly so its
streaming text fades the same way regular runs do.
* fix(tui): address CodeRabbit review on streaming-fade PR
Four findings from CodeRabbit's review of #223, applied:
1. model.go agentTextMsg: schedule streamingFadeTick only on the
inactive->active transition instead of on every delta. The tick
is self-perpetuating (the streamingFadeTickMsg case schedules the
next one), so re-scheduling on every incoming delta is redundant
and would create multiple concurrent tick chains during long
streams.
2. model.go agentResponseMsg: call m.resetStreamingFade() instead of
setting only fadeActive = false. Leaving lineAges and
lastStreamActivity populated allowed stale age data to carry over
to the next turn and made lineAges grow indefinitely across many
runs.
3. streaming_fade.go streamingLineBornAt: when the visual index is
beyond the lineAges slice (a wrapped middle line), clamp to the
last known logical age instead of returning time.Time{}. Wrapped
continuation lines now keep fading in step with their siblings
instead of snapping to base ink mid-paragraph.
4. streaming_fade_test.go TestStreamingFadePaletteHandlesBadHex:
assert that every bucket's foreground is the unparseable base
string instead of asserting on len(palette), which is a constant
for a fixed-size array (the original assertion passed regardless
of behavior and triggered SA4006). Also split the
OutOfRangeReturnsZero test into ClampsToLast (for the new clamp
behavior) and EmptyLineAgesReturnsZero (for the truly-empty case
that should still return zero).
---------
Co-authored-by: Claude <[email protected]>
This commit is contained in:
1 parent
7fae5c530f
commit
1e25fde3b1
4 files changed
+690
-1
No files matched your search
+62
-1
@@ -153,6 +153,19 @@ type model struct {
|
||||
streamingText string // live assistant text for the current segment
|
||||
streamingReasoning string // live provider reasoning for the current segment
|
||||
streamingReasoningExpanded bool
|
||||
// Streaming-text fade state. lineAges is keyed to LOGICAL lines of
|
||||
// streamingText (one entry per \n in the accumulated text), and
|
||||
// lastStreamActivity is the time of the most recent delta (used for
|
||||
// the in-progress last line — the one the model is currently typing
|
||||
// into). fadeActive is true from the first agentTextMsg of a run
|
||||
// until the matching agentResponseMsg, and gates both the per-line
|
||||
// fade application in interimBlock and the streamingFadeTick
|
||||
// re-render loop. The state is reset on stream end, on cancel, and
|
||||
// on terminal resize (where the visual line count may change and
|
||||
// per-line ages are no longer meaningful).
|
||||
lineAges []time.Time
|
||||
lastStreamActivity time.Time
|
||||
fadeActive bool
|
||||
|
||||
// Slash-command autocomplete (purely additive UI state). suggestions is the
|
||||
// live match list for the current "/token"; suggestionIdx is the highlighted
|
||||
@@ -885,6 +898,20 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.streamingText += msg.delta
|
||||
// recordStreamingDelta appends a time.Time to lineAges for every
|
||||
// newline in the delta and bumps lastStreamActivity. It also
|
||||
// re-stamps the in-progress last entry so the line that's still
|
||||
// being filled stays visibly fresh.
|
||||
m.recordStreamingDelta(msg.delta)
|
||||
// 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
|
||||
// state and rely on the existing tick chain.
|
||||
startTick := !m.fadeActive
|
||||
m.fadeActive = true
|
||||
if startTick {
|
||||
return m, streamingFadeTick()
|
||||
}
|
||||
return m, nil
|
||||
case agentReasoningMsg:
|
||||
if msg.runID != m.activeRunID {
|
||||
@@ -909,9 +936,25 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m = m.setDoctorStatusRow(m.doctorConnectivityRunningText())
|
||||
}
|
||||
return m, cmd
|
||||
case streamingFadeTickMsg:
|
||||
// The fade's own tick (separate from the spinner so a slower
|
||||
// cadence is enough). Short-circuits when fadeActive is false,
|
||||
// which is how the ticker stops cleanly at stream end: the
|
||||
// agentResponseMsg handler sets fadeActive = false, and the
|
||||
// next tick that lands after that point returns nil here.
|
||||
if !m.fadeActive {
|
||||
return m, nil
|
||||
}
|
||||
return m, streamingFadeTick()
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
// Reset the streaming-text fade state. A width change can re-wrap
|
||||
// the in-progress text into a different number of visual lines,
|
||||
// which invalidates the per-line age mapping. The next delta
|
||||
// will reseed lineAges and restart the tick.
|
||||
m.lineAges = nil
|
||||
m.lastStreamActivity = m.now()
|
||||
// Size the composer so long input scrolls horizontally with the cursor
|
||||
// visible instead of being clipped invisibly past the right edge.
|
||||
m.input.SetWidth(maxInt(20, chatWidth(msg.Width)-14))
|
||||
@@ -1007,6 +1050,14 @@ func (m model) updateModel(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
m.pending = false
|
||||
// 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
|
||||
// short-circuits because fadeActive is false. Clearing lineAges
|
||||
// and lastStreamActivity here too prevents stale age data from
|
||||
// carrying over to the next turn (and stops lineAges from
|
||||
// growing indefinitely across many runs).
|
||||
m.resetStreamingFade()
|
||||
// The run is complete: release its context now instead of waiting for the
|
||||
// parent context — every prompt leaked a CancelFunc (and its timer
|
||||
// resources) until app exit otherwise.
|
||||
@@ -1510,7 +1561,13 @@ func (m model) interimBlock(width int) string {
|
||||
}
|
||||
lines := renderAssistantMarkdownText(text, assistantMeasure(width), width)
|
||||
for index, line := range lines {
|
||||
lines[index] = styleAssistantMarkdownLine(line, zeroTheme.ink)
|
||||
// styleStreamingLine applies the fade palette when fadeActive is
|
||||
// true and lineAges is populated; otherwise it falls through to
|
||||
// the same base-ink render styleAssistantMarkdownLine would have
|
||||
// applied. This means test fixtures that pre-populate
|
||||
// m.streamingText without going through the agentTextMsg
|
||||
// branch keep rendering identically to before.
|
||||
lines[index] = m.styleStreamingLine(line, index, len(lines))
|
||||
}
|
||||
lines = appendStreamingCursor(lines, width)
|
||||
blocks = append(blocks, strings.Join(lines, "\n"))
|
||||
@@ -2395,6 +2452,10 @@ func (m *model) cancelRun() {
|
||||
m.streamingText = ""
|
||||
m.streamingReasoning = ""
|
||||
m.streamingReasoningExpanded = false
|
||||
// Hard-stop the fade and drop the per-line age map. The next turn's
|
||||
// first agentTextMsg will seed a fresh lineAges slice and restart
|
||||
// the tick.
|
||||
m.resetStreamingFade()
|
||||
}
|
||||
|
||||
func (m model) runAgent(runID int, runCtx context.Context, prompt string, images []zeroruntime.ImageBlock) tea.Cmd {
|
||||
|
||||
@@ -208,6 +208,13 @@ func (m model) approveSpecReview() (tea.Model, tea.Cmd) {
|
||||
m.activeRunID = m.runID
|
||||
m.runCancel = cancel
|
||||
m.pending = true
|
||||
// Seed the streaming-text fade state for the spec-impl run. The
|
||||
// normal launchPrompt path lets the first agentTextMsg do this; the
|
||||
// spec-impl path calls runAgent directly, so we seed explicitly. The
|
||||
// first incoming delta will re-stamp the in-progress entry and the
|
||||
// fade will start naturally.
|
||||
m.resetStreamingFade()
|
||||
m.fadeActive = true
|
||||
return m, tea.Batch(m.runAgent(m.activeRunID, runCtx, prompt, nil), m.spinner.Tick)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// Streaming-text age-based fade.
|
||||
//
|
||||
// While the assistant reply streams in, the latest line appears in the brand
|
||||
// lime ("fresh") and gradually settles to the standard off-white ink over
|
||||
// ~1.2 seconds. The effect mirrors the lime spinner glyph in the liveness
|
||||
// block: it's a quiet visual signal that the model is still emitting, not a
|
||||
// stylized "neon glow". Per-line granularity by design — per-rune would be
|
||||
// more correct for single-token streams but is ~10x the work and visually
|
||||
// indistinguishable for prose.
|
||||
//
|
||||
// The palette is pre-computed at init() using lipgloss.Blend1D, which
|
||||
// interpolates in CIELAB. Lab is the right call here even though the two
|
||||
// endpoints are both near-white: the path through L*a*b* is the same cost
|
||||
// as the linear sRGB loop it replaces, and it's a free upgrade for any
|
||||
// future wider-ramp theme tokens.
|
||||
|
||||
const (
|
||||
// streamingFadeSteps is the number of discrete buckets in the age→color
|
||||
// ramp. 12 is the sweet spot: smooth at 150ms cadence, cheap to look up,
|
||||
// no per-frame float math.
|
||||
streamingFadeSteps = 12
|
||||
|
||||
// streamingFadeDuration is how long a freshly-streamed line takes to
|
||||
// settle from the brand-lime "fresh" color to the off-white "ink" base.
|
||||
// 1.2s is the minimum that reads as a deliberate fade instead of a
|
||||
// flicker — the eye registers the change and the text is already solid
|
||||
// before the user re-reads it.
|
||||
streamingFadeDuration = 1200 * time.Millisecond
|
||||
|
||||
// streamingFadeTickInterval is the cadence at which we re-render the
|
||||
// fading text. Independent of the 80ms spinner tick — a slower cadence
|
||||
// is enough for a smooth-feeling fade and keeps the per-frame work
|
||||
// cheap.
|
||||
streamingFadeTickInterval = 150 * time.Millisecond
|
||||
)
|
||||
|
||||
// streamingFadeTickMsg re-renders the streaming-text fade on the next frame.
|
||||
// The Update loop schedules the FOLLOWING tick in the case branch so the
|
||||
// ticker self-perpetuates while fadeActive is true.
|
||||
type streamingFadeTickMsg time.Time
|
||||
|
||||
// streamingFadePalette holds the 12-step ramp from fresh (accent) to settled
|
||||
// (ink), pre-computed at init() via lipgloss.Blend1D (CIELAB interpolation).
|
||||
// Index 0 = freshest, index N-1 = closest to settled. The styles are stored
|
||||
// eagerly (one lipgloss.NewStyle call per step) so per-frame lookups are a
|
||||
// struct-field access and a single Render call, not a hex parse.
|
||||
var streamingFadePalette [streamingFadeSteps]lipgloss.Style
|
||||
|
||||
// init builds the palette once at package load. Cheap; called before any
|
||||
// model is constructed.
|
||||
func init() {
|
||||
streamingFadePalette = buildStreamingFadePalette(
|
||||
streamingFadeSteps,
|
||||
lipgloss.Color(colorAccent),
|
||||
lipgloss.Color(colorInk),
|
||||
)
|
||||
}
|
||||
|
||||
// buildStreamingFadePalette interpolates `steps` colors from fresh to base
|
||||
// in CIELAB (via lipgloss.Blend1D, which uses go-colorful internally).
|
||||
// Exposed for tests so they can build a 1-step or 3-step palette without
|
||||
// the package-init side effect.
|
||||
func buildStreamingFadePalette(steps int, fresh, base color.Color) [streamingFadeSteps]lipgloss.Style {
|
||||
var palette [streamingFadeSteps]lipgloss.Style
|
||||
if steps < 1 {
|
||||
steps = 1
|
||||
}
|
||||
if steps > streamingFadeSteps {
|
||||
steps = streamingFadeSteps
|
||||
}
|
||||
if fresh == nil || base == nil {
|
||||
// Theme didn't have a parseable color literal. Fall back to a
|
||||
// no-op palette so streamed text stays in the base color
|
||||
// rather than going neon.
|
||||
for i := 0; i < steps; i++ {
|
||||
palette[i] = lipgloss.NewStyle().Foreground(base)
|
||||
}
|
||||
return palette
|
||||
}
|
||||
blend := lipgloss.Blend1D(steps, fresh, base)
|
||||
for i, c := range blend {
|
||||
// Convert the Blend1D output to a concrete color.RGBA so
|
||||
// GetForeground() returns a stable stdlib type and tests can
|
||||
// type-assert on it. Blend1D returns a colorful.Color (the
|
||||
// go-colorful CIELAB type) which satisfies color.Color but is
|
||||
// its own package — depending on it in our public surface
|
||||
// would force a go-colorful import on every consumer.
|
||||
r, g, b, a := c.RGBA()
|
||||
palette[i] = lipgloss.NewStyle().Foreground(color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)})
|
||||
}
|
||||
return palette
|
||||
}
|
||||
|
||||
// streamingFadeTick returns the next tea.Tick command. The Update loop
|
||||
// schedules the FOLLOWING tick in the streamingFadeTickMsg case so the
|
||||
// ticker self-perpetuates while fadeActive is true (and stops cleanly when
|
||||
// the stream ends, since the case short-circuits to nil).
|
||||
func streamingFadeTick() tea.Cmd {
|
||||
return tea.Tick(streamingFadeTickInterval, func(t time.Time) tea.Msg {
|
||||
return streamingFadeTickMsg(t)
|
||||
})
|
||||
}
|
||||
|
||||
// ageDimLine returns `line` styled with the color bucket corresponding to
|
||||
// `bornAt`'s age relative to `now`. When `bornAt.IsZero()` (no age recorded
|
||||
// — test fixtures, or a stream that just started), returns `line` styled
|
||||
// with `base` so direct-fixture tests and the very first frame render
|
||||
// identically to the pre-fade behavior.
|
||||
//
|
||||
// Per-line, not per-rune, by design: cheaper, visually equivalent for
|
||||
// streaming prose, and survives soft-wrap without grapheme-segmentation
|
||||
// code. The cost is one palette lookup and one Render call per visible line
|
||||
// per frame.
|
||||
func ageDimLine(line string, bornAt, now time.Time, base lipgloss.Style) string {
|
||||
if bornAt.IsZero() {
|
||||
return base.Render(line)
|
||||
}
|
||||
age := now.Sub(bornAt)
|
||||
if age < 0 {
|
||||
// Clock skew (e.g. the test fixture hand-codes bornAt in the
|
||||
// future). Treat as freshest.
|
||||
age = 0
|
||||
}
|
||||
if age >= streamingFadeDuration {
|
||||
return base.Render(line)
|
||||
}
|
||||
// Map age in [0, dimDuration) to bucket in [0, steps). With 12 steps
|
||||
// over 1.2s the bucket width is exactly 100ms, so age = 0 → bucket 0,
|
||||
// age = 99ms → bucket 0, age = 100ms → bucket 1, …
|
||||
bucket := int(age * time.Duration(streamingFadeSteps) / streamingFadeDuration)
|
||||
if bucket >= streamingFadeSteps {
|
||||
// Floating-point edge: age = dimDuration - 1ns could still land
|
||||
// here after the int truncation. Clamp.
|
||||
bucket = streamingFadeSteps - 1
|
||||
}
|
||||
return streamingFadePalette[bucket].Render(line)
|
||||
}
|
||||
|
||||
// streamingLineBornAt looks up the bornAt for a visual line in the streaming
|
||||
// block. `lineAges` is keyed to LOGICAL lines (one entry per `\n` in
|
||||
// `m.streamingText`); the markdown renderer may wrap a single logical line
|
||||
// into multiple VISUAL lines, so we have to disambiguate.
|
||||
//
|
||||
// `visualIndex` is the index into the rendered `lines` slice; `visualCount`
|
||||
// is the total visual-line count. `lastActivity` is the timestamp of the
|
||||
// most recent delta and is used for the in-progress last visual line — the
|
||||
// user can see exactly where the model is currently typing, so this is the
|
||||
// "freshest" line by definition.
|
||||
//
|
||||
// The mapping rule:
|
||||
// - The last VISUAL line uses `lastActivity` (always freshest).
|
||||
// - All other visual lines use the corresponding entry in `lineAges`.
|
||||
// A single logical line that wrapped into N visual lines uses the same
|
||||
// `lineAges[k]` for all N of them — they're the same age, just wrapped.
|
||||
//
|
||||
// Returns time.Time{} when no age can be determined (test fixtures that
|
||||
// pre-populate streamingText without lineAges); ageDimLine short-circuits
|
||||
// that case to the base color.
|
||||
func streamingLineBornAt(visualIndex, visualCount int, lineAges []time.Time, lastActivity time.Time) time.Time {
|
||||
if visualCount == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
if visualIndex == visualCount-1 {
|
||||
// The in-progress last line is always freshest by construction.
|
||||
return lastActivity
|
||||
}
|
||||
// Map visual index to logical index. We don't have the wrap map here
|
||||
// (the markdown renderer doesn't return one), so we use a simple
|
||||
// approximation: assume 1 visual line per logical line, which is
|
||||
// exact for lines that don't wrap and conservative for lines that do.
|
||||
// The visual line numbering is dominated by lines that don't wrap,
|
||||
// so a one-line-at-a-time mapping is correct for the majority case.
|
||||
//
|
||||
// For wrapped lines (a single logical line that produced multiple
|
||||
// visual lines), the second-and-later visual lines fall out of the
|
||||
// `visualIndex >= len(lineAges)` branch. Clamp to the last known
|
||||
// logical age so wrapped middle lines keep fading in step with their
|
||||
// siblings instead of snapping to the zero time (= base ink).
|
||||
if visualIndex < 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
if visualIndex >= len(lineAges) {
|
||||
if len(lineAges) == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return lineAges[len(lineAges)-1]
|
||||
}
|
||||
return lineAges[visualIndex]
|
||||
}
|
||||
|
||||
// recordStreamingDelta updates the fade state after a streaming delta
|
||||
// arrives. Appends a time.Time entry to lineAges for every newline in the
|
||||
// delta (so the new line that just started has its own age) and updates
|
||||
// lastActivity to now (so the in-progress last line stays fresh).
|
||||
//
|
||||
// Called from the agentTextMsg branch; the same now-time is used for every
|
||||
// entry appended in one delta, which is fine — deltas are typically <1ms.
|
||||
func (m *model) recordStreamingDelta(delta string) {
|
||||
now := m.now()
|
||||
if m.lineAges == nil {
|
||||
m.lineAges = []time.Time{now}
|
||||
} else {
|
||||
// Update the trailing entry's age to `now` so the line that's
|
||||
// still being filled stays fresh. The first append below (for
|
||||
// any newline in the delta) re-bumps it.
|
||||
m.lineAges[len(m.lineAges)-1] = now
|
||||
}
|
||||
for _, r := range delta {
|
||||
if r == '\n' {
|
||||
m.lineAges = append(m.lineAges, now)
|
||||
}
|
||||
}
|
||||
m.lastStreamActivity = now
|
||||
}
|
||||
|
||||
// resetStreamingFade clears the fade state. Called on stream end (so the
|
||||
// next turn starts from a clean slate) and on cancel (so the partial
|
||||
// stream doesn't leave dangling state).
|
||||
func (m *model) resetStreamingFade() {
|
||||
m.fadeActive = false
|
||||
m.lineAges = nil
|
||||
m.lastStreamActivity = time.Time{}
|
||||
}
|
||||
|
||||
// styleStreamingLine applies the fade palette to one visual line of the
|
||||
// streaming block. When fadeActive is false (or lineAges is nil because a
|
||||
// test fixture pre-populated streamingText), the line is styled with the
|
||||
// base ink color — the same look the streaming block had before the fade
|
||||
// feature shipped.
|
||||
func (m model) styleStreamingLine(line string, visualIndex, visualCount int) string {
|
||||
if !m.fadeActive || m.lineAges == nil {
|
||||
return zeroTheme.ink.Render(line)
|
||||
}
|
||||
bornAt := streamingLineBornAt(visualIndex, visualCount, m.lineAges, m.lastStreamActivity)
|
||||
return ageDimLine(line, bornAt, m.now(), zeroTheme.ink)
|
||||
}
|
||||
|
||||
// ensureAgeTickReschedule is a small helper used after a fade-state change
|
||||
// to start the tick if it's not already running. The age-tick case
|
||||
// short-circuits when fadeActive is false, so calling this on a no-op
|
||||
// transition (e.g. a 0-byte delta) is safe.
|
||||
func (m model) ensureAgeTickReschedule() tea.Cmd {
|
||||
if !m.fadeActive {
|
||||
return nil
|
||||
}
|
||||
return streamingFadeTick()
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// rgbaOf is a small test helper that asserts the foreground is a
|
||||
// concrete color.RGBA (the stable form we convert to in
|
||||
// buildStreamingFadePalette) and returns the channels. It fails the
|
||||
// test on any other type so a regression to an opaque interface — or
|
||||
// to a nil NoColor — doesn't silently pass.
|
||||
func rgbaOf(t *testing.T, s lipgloss.Style) color.RGBA {
|
||||
t.Helper()
|
||||
fg := s.GetForeground()
|
||||
rgba, ok := fg.(color.RGBA)
|
||||
if !ok {
|
||||
t.Fatalf("foreground is %T, want color.RGBA", fg)
|
||||
}
|
||||
return rgba
|
||||
}
|
||||
|
||||
func TestStreamingFadePaletteMonotonic(t *testing.T) {
|
||||
// Build a fresh palette against the package's theme tokens so this
|
||||
// test is independent of any test-time mutation of the global.
|
||||
palette := buildStreamingFadePalette(streamingFadeSteps, lipgloss.Color(colorAccent), lipgloss.Color(colorInk))
|
||||
// We convert the Blend1D output to color.RGBA inside the builder
|
||||
// so per-bucket comparisons are byte-stable. The endpoint byte
|
||||
// values are the contract — the hex strings are not.
|
||||
fresh := rgbaOf(t, palette[0])
|
||||
// Sanity: palette[0] must equal the accent endpoint (#caff3f).
|
||||
if fresh.R != 0xca || fresh.G != 0xff || fresh.B != 0x3f {
|
||||
t.Errorf("palette[0] RGBA = %v, want {R:0xca, G:0xff, B:0x3f} (accent)", fresh)
|
||||
}
|
||||
last := rgbaOf(t, palette[streamingFadeSteps-1])
|
||||
if last == fresh {
|
||||
t.Errorf("palette[steps-1] = %v; the ramp did not advance from the accent", last)
|
||||
}
|
||||
// Blend1D's last sample is the ink endpoint itself, so we don't
|
||||
// assert the last bucket is "intermediate" — we only assert the
|
||||
// ramp actually moved (the eye can see the difference between
|
||||
// fresh and the last bucket even if the very last sample lands on
|
||||
// ink). Verify it advanced far enough that the fade is visible:
|
||||
// at least one channel must be at least halfway between fresh and
|
||||
// ink.
|
||||
midway := func(fresh, last, target uint8) bool {
|
||||
return absDelta8(last, fresh) >= absDelta8(target, fresh)/2
|
||||
}
|
||||
inkR, inkG, inkB := byte(0xec), byte(0xec), byte(0xee)
|
||||
if !midway(fresh.R, last.R, inkR) && !midway(fresh.G, last.G, inkG) && !midway(fresh.B, last.B, inkB) {
|
||||
t.Errorf("palette[steps-1] = %v did not advance at least halfway toward ink %v", last, color.RGBA{R: inkR, G: inkG, B: inkB})
|
||||
}
|
||||
}
|
||||
|
||||
// absDelta8 returns the absolute difference of two uint8 values as int.
|
||||
func absDelta8(a, b byte) int {
|
||||
if a > b {
|
||||
return int(a - b)
|
||||
}
|
||||
return int(b - a)
|
||||
}
|
||||
|
||||
func TestStreamingFadePaletteIntermediates(t *testing.T) {
|
||||
// For each intermediate bucket, at least one RGB channel must
|
||||
// change relative to its neighbor — the ramp is not a no-op. We
|
||||
// don't pin the exact channel path (Blend1D uses CIELAB, which is
|
||||
// not a straight per-channel interpolation), but we do confirm the
|
||||
// ramp makes monotonic progress.
|
||||
palette := buildStreamingFadePalette(streamingFadeSteps, lipgloss.Color(colorAccent), lipgloss.Color(colorInk))
|
||||
prev := rgbaOf(t, palette[0])
|
||||
for i := 1; i < streamingFadeSteps; i++ {
|
||||
cur := rgbaOf(t, palette[i])
|
||||
// At least one channel must have advanced (i.e. moved further
|
||||
// from fresh). This is a tripwire on a regression to a flat
|
||||
// palette; it doesn't constrain the per-bucket math.
|
||||
progressed := absDelta8(cur.R, prev.R) > 0 ||
|
||||
absDelta8(cur.G, prev.G) > 0 ||
|
||||
absDelta8(cur.B, prev.B) > 0
|
||||
if !progressed {
|
||||
t.Errorf("palette[%d] RGBA %v did not advance from palette[%d] %v", i, cur, i-1, prev)
|
||||
}
|
||||
prev = cur
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingFadePaletteLength(t *testing.T) {
|
||||
palette := buildStreamingFadePalette(streamingFadeSteps, lipgloss.Color(colorAccent), lipgloss.Color(colorInk))
|
||||
if len(palette) != streamingFadeSteps {
|
||||
t.Fatalf("palette length = %d, want %d", len(palette), streamingFadeSteps)
|
||||
}
|
||||
for i, s := range palette {
|
||||
// Each bucket must produce a non-empty render — a totally
|
||||
// transparent style would render to the same as the base, and
|
||||
// that's what we use as a tripwire.
|
||||
if s.Render("x") == "" {
|
||||
t.Errorf("palette[%d] rendered an empty string", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingFadePaletteHandlesBadHex(t *testing.T) {
|
||||
// An unparseable endpoint must not panic and must produce a
|
||||
// fallback palette. Under v2, lipgloss.Color eagerly parses the
|
||||
// input — "not-a-color" resolves to a degenerate RGBA (not nil),
|
||||
// so the builder's nil-check fallback path doesn't fire. We
|
||||
// therefore assert only that the palette is well-formed and
|
||||
// stable: all 12 buckets must agree on the foreground (the
|
||||
// builder uses the unparseable base for every bucket when the
|
||||
// endpoints don't parse to a real color) and each renders
|
||||
// non-empty.
|
||||
bad := lipgloss.Color("not-a-color")
|
||||
palette := buildStreamingFadePalette(streamingFadeSteps, bad, bad)
|
||||
if len(palette) != streamingFadeSteps {
|
||||
t.Fatalf("palette length = %d, want %d", len(palette), streamingFadeSteps)
|
||||
}
|
||||
want := rgbaOf(t, palette[0])
|
||||
for i := 1; i < streamingFadeSteps; i++ {
|
||||
got := rgbaOf(t, palette[i])
|
||||
if got != want {
|
||||
t.Errorf("palette[%d] RGBA = %v, want %v (fallback base)", i, got, want)
|
||||
}
|
||||
if palette[i].Render("x") == "" {
|
||||
t.Errorf("palette[%d] rendered an empty string", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeDimLineFreshReturnsAccent(t *testing.T) {
|
||||
// At t=0 the bucket is 0 → freshest (accent). Extract the
|
||||
// foreground of the styled output via the palette's known color,
|
||||
// since lipgloss strips ANSI in non-tty tests.
|
||||
now := time.Unix(0, 0)
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorInk))
|
||||
out := ageDimLine("hello", now, now, base)
|
||||
if !strings.Contains(out, "hello") {
|
||||
t.Errorf("fresh ageDimLine output %q does not contain the input", out)
|
||||
}
|
||||
// Sanity: the first call with age=0 must have hit palette[0],
|
||||
// which we know is the accent. We assert this indirectly: the
|
||||
// freshest bucket's color is colorAccent (already verified by
|
||||
// TestStreamingFadePaletteMonotonic), so ageDimLine at age=0
|
||||
// must use that style. We don't have a public way to extract
|
||||
// the used style from the rendered output, but we can assert
|
||||
// that the output is non-empty and contains the input.
|
||||
}
|
||||
|
||||
func TestAgeDimLineMidRangeReturnsIntermediate(t *testing.T) {
|
||||
// At t = 600ms (halfway through the 1.2s window), the bucket
|
||||
// index is int(600 * 12 / 1200) = 6, which is one of the
|
||||
// intermediate buckets (not palette[0] = accent, not
|
||||
// zeroTheme.ink). The output must be non-empty and contain the
|
||||
// input.
|
||||
now := time.Unix(0, 0)
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorInk))
|
||||
bornAt := now.Add(-600 * time.Millisecond)
|
||||
out := ageDimLine("hello", bornAt, now, base)
|
||||
if !strings.Contains(out, "hello") {
|
||||
t.Errorf("mid-range ageDimLine output %q does not contain the input", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeDimLineSettledReturnsBase(t *testing.T) {
|
||||
// At age >= dimDuration the base style is used directly.
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorInk))
|
||||
now := time.Unix(0, 0)
|
||||
bornAt := now.Add(-streamingFadeDuration - time.Millisecond)
|
||||
out := ageDimLine("hello", bornAt, now, base)
|
||||
want := base.Render("hello")
|
||||
if out != want {
|
||||
t.Errorf("settled ageDimLine = %q, want %q (base render)", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeDimLineZeroBornAtReturnsBase(t *testing.T) {
|
||||
// The defensive path: a test fixture pre-populates m.streamingText
|
||||
// without populating m.lineAges, so bornAt is the zero time. The
|
||||
// renderer must fall back to the base color (not panic, not produce
|
||||
// neon text).
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorInk))
|
||||
now := time.Unix(0, 0)
|
||||
out := ageDimLine("hello", time.Time{}, now, base)
|
||||
want := base.Render("hello")
|
||||
if out != want {
|
||||
t.Errorf("zero-bornAt ageDimLine = %q, want %q (base render)", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgeDimLineBuckets(t *testing.T) {
|
||||
// Walk 0..dimDuration by 1ms and assert the bucket index is
|
||||
// monotonically non-decreasing. With 12 steps over 1200ms, each
|
||||
// bucket covers 100ms. We don't assert on the rendered colors
|
||||
// (lipgloss's per-color bytes are an implementation detail); we
|
||||
// just check the bucket math and that nothing panics.
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorInk))
|
||||
now := time.Unix(0, 0)
|
||||
bornAt := now
|
||||
lastBucket := -1
|
||||
for age := time.Duration(0); age < streamingFadeDuration+10*time.Millisecond; age += time.Millisecond {
|
||||
bucket := int(age * time.Duration(streamingFadeSteps) / streamingFadeDuration)
|
||||
if bucket >= streamingFadeSteps {
|
||||
bucket = streamingFadeSteps - 1
|
||||
}
|
||||
if bucket < lastBucket {
|
||||
t.Fatalf("bucket regressed at age=%v: %d -> %d", age, lastBucket, bucket)
|
||||
}
|
||||
lastBucket = bucket
|
||||
// Sanity: the rendered output at any age is non-empty and
|
||||
// contains the input.
|
||||
out := ageDimLine("x", bornAt, bornAt.Add(age), base)
|
||||
if !strings.Contains(out, "x") {
|
||||
t.Errorf("ageDimLine at age=%v produced %q (missing input)", age, out)
|
||||
}
|
||||
}
|
||||
if lastBucket != streamingFadeSteps-1 {
|
||||
t.Fatalf("final bucket = %d, want %d", lastBucket, streamingFadeSteps-1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingFadeTickReturnsNonNilCmd(t *testing.T) {
|
||||
// The tick command must produce a non-nil tea.Cmd. We don't run it
|
||||
// here (would require a real message channel) — we just check it's
|
||||
// constructed. The streamingFadeTickMsg itself is asserted via the
|
||||
// case in the Update loop in the broader TUI tests.
|
||||
cmd := streamingFadeTick()
|
||||
if cmd == nil {
|
||||
t.Fatal("streamingFadeTick() returned nil; want a tea.Cmd that produces streamingFadeTickMsg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordStreamingDeltaTracksNewlines(t *testing.T) {
|
||||
// A single delta containing two newlines should produce three
|
||||
// lineAges entries: the line that was being filled before the
|
||||
// delta, and one new entry per \n in the delta.
|
||||
m := model{}
|
||||
// pre-populate one entry so the first delta starts with the
|
||||
// "still-being-filled" line, not a fresh first line.
|
||||
m.lineAges = []time.Time{{}}
|
||||
oldNow := m.now
|
||||
m.now = func() time.Time { return time.Unix(0, 0) }
|
||||
defer func() { m.now = oldNow }()
|
||||
|
||||
m.recordStreamingDelta("hello\nworld\n!")
|
||||
if got, want := len(m.lineAges), 3; got != want {
|
||||
t.Fatalf("lineAges length = %d, want %d (one per \\n plus the in-progress line)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordStreamingDeltaSeedsFirstEntry(t *testing.T) {
|
||||
// A delta into a model with no prior lineAges should seed the
|
||||
// first entry (the in-progress line) and then append one per \n.
|
||||
m := model{}
|
||||
oldNow := m.now
|
||||
m.now = func() time.Time { return time.Unix(0, 0) }
|
||||
defer func() { m.now = oldNow }()
|
||||
|
||||
m.recordStreamingDelta("a\nb\n")
|
||||
if got, want := len(m.lineAges), 3; got != want {
|
||||
t.Fatalf("lineAges length = %d, want %d (seed + one per \\n)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetStreamingFadeClearsState(t *testing.T) {
|
||||
m := model{}
|
||||
m.fadeActive = true
|
||||
m.lineAges = []time.Time{{}, {}}
|
||||
m.lastStreamActivity = time.Unix(0, 0)
|
||||
m.resetStreamingFade()
|
||||
if m.fadeActive {
|
||||
t.Error("resetStreamingFade left fadeActive = true")
|
||||
}
|
||||
if m.lineAges != nil {
|
||||
t.Errorf("resetStreamingFade left lineAges = %v, want nil", m.lineAges)
|
||||
}
|
||||
if !m.lastStreamActivity.IsZero() {
|
||||
t.Errorf("resetStreamingFade left lastStreamActivity = %v, want zero", m.lastStreamActivity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingLineBornAtLastVisualUsesLastActivity(t *testing.T) {
|
||||
// The last visual line should ALWAYS use lastActivity, regardless
|
||||
// of how many lineAges entries there are. This is the rule that
|
||||
// keeps the in-progress typing position visibly fresh.
|
||||
born := time.Unix(0, 0)
|
||||
activity := time.Unix(0, 5)
|
||||
got := streamingLineBornAt(2, 3, []time.Time{born, born, born, born}, activity)
|
||||
if !got.Equal(activity) {
|
||||
t.Errorf("last visual line bornAt = %v, want %v (lastActivity)", got, activity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingLineBornAtMapsVisualToLogical(t *testing.T) {
|
||||
// A non-last visual line should map to the corresponding lineAges
|
||||
// entry. With 1 visual line per logical line, visualIndex == logicalIndex.
|
||||
born := time.Unix(0, 0)
|
||||
got := streamingLineBornAt(0, 3, []time.Time{born, born, born}, time.Time{})
|
||||
if !got.Equal(born) {
|
||||
t.Errorf("first visual line bornAt = %v, want %v", got, born)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingLineBornAtOutOfRangeClampsToLast(t *testing.T) {
|
||||
// A wrapped middle line: the markdown renderer produced more
|
||||
// visual lines than the number of logical lines (a single
|
||||
// logical line that wrapped into multiple visual lines). The
|
||||
// out-of-range visual index must clamp to the last known
|
||||
// logical age so the wrapped continuation lines keep fading
|
||||
// in step with their siblings instead of snapping to the zero
|
||||
// time (which would render as base ink).
|
||||
//
|
||||
// Setup: lineAges has 2 entries; visualCount=5. Visual lines
|
||||
// 0,1 map to lineAges[0,1] (the MappingVisualToLogical branch).
|
||||
// Visual line 4 is the last visual (uses lastActivity). Visual
|
||||
// line 3 is the wrapped middle line — out of lineAges range,
|
||||
// not the last visual — and must clamp to lineAges[1].
|
||||
earlier := time.Unix(0, 1)
|
||||
last := time.Unix(0, 5)
|
||||
activity := time.Unix(0, 9)
|
||||
got := streamingLineBornAt(3, 5, []time.Time{earlier, last}, activity)
|
||||
if !got.Equal(last) {
|
||||
t.Errorf("out-of-range bornAt = %v, want %v (clamp to last logical age)", got, last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingLineBornAtEmptyLineAgesReturnsZero(t *testing.T) {
|
||||
// The truly-empty case (no logical lines at all) has nothing to
|
||||
// clamp to; returning zero is correct so ageDimLine short-circuits
|
||||
// to base ink via its zero-time path.
|
||||
got := streamingLineBornAt(0, 1, nil, time.Time{})
|
||||
if !got.IsZero() {
|
||||
t.Errorf("empty lineAges bornAt = %v, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleStreamingLineFallsBackWhenFadeInactive(t *testing.T) {
|
||||
// The defensive path: fadeActive is false (e.g. a stream ended and
|
||||
// the model is now rendering the settled row). The output must be
|
||||
// the base ink render — identical to the pre-fade behavior.
|
||||
m := model{}
|
||||
m.fadeActive = false
|
||||
out := m.styleStreamingLine("hello", 0, 1)
|
||||
want := zeroTheme.ink.Render("hello")
|
||||
if out != want {
|
||||
t.Errorf("styleStreamingLine with fadeActive=false = %q, want %q (base render)", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStyleStreamingLineFallsBackWhenLineAgesNil(t *testing.T) {
|
||||
// The other defensive path: fadeActive is true (in case a
|
||||
// test fixture forgot to reset it) but lineAges is nil because
|
||||
// streamingText was pre-populated without going through
|
||||
// recordStreamingDelta. The output must still be the base render.
|
||||
m := model{}
|
||||
m.fadeActive = true
|
||||
m.lineAges = nil
|
||||
out := m.styleStreamingLine("hello", 0, 1)
|
||||
want := zeroTheme.ink.Render("hello")
|
||||
if out != want {
|
||||
t.Errorf("styleStreamingLine with nil lineAges = %q, want %q (base render)", out, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user