feat(tui): age-based fade for streaming assistant text#223
Conversation
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.
|
Quick note on the design: I dug into Claude Code to see whether they actually do per-character color fade. They don't — the "glow" feel there is a block-boundary split ( The closest reference I found in a real Go TUI is So this PR isn't reproducing the Claude Code effect exactly; it's a new effect that reads like what people remember. If we want to go further later, the upgrade paths are:
Happy to follow up on any of these as separate PRs if useful. |
Zero automated PR reviewVerdict: No blockers found Blockers
Validation
ScopeHead: This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality. |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
🚧 Files skipped from review as they are similar to previous changes (2)
WalkthroughAdds a timed "fade" effect for streamed assistant text in the TUI. A new ChangesStreaming Fade Effect
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/tui/model.go`:
- Around line 1032-1037: On stream completion in the fade state management
block, you are only setting fadeActive to false, but this leaves lineAges and
lastStreamActivity still populated, allowing stale data to persist across runs
and causing lineAges to grow indefinitely. In addition to setting fadeActive =
false, also reset lineAges to an empty state and clear lastStreamActivity to
fully reset the fade state at stream completion.
- Around line 884-894: In the agentTextMsg case handler where streamingFadeTick
is scheduled, the fade ticker is being started on every incoming delta message.
To prevent multiple concurrent tick chains during long streams, check if
fadeActive is already true before scheduling streamingFadeTick. Only schedule
the ticker on the transition from fadeActive being false to true, by checking
the current value of m.fadeActive before setting it to true and conditionally
returning streamingFadeTick() only when this is a transition from inactive to
active state.
In `@internal/tui/streaming_fade_test.go`:
- Around line 93-96: The length check in TestStreamingFadePaletteHandlesBadHex
is ineffective because the palette returned from buildStreamingFadePalette is a
fixed-size array, making the length assertion constant and non-deterministic.
Replace the len(palette) check with actual assertions that validate the palette
elements contain the correct fallback bucket foreground colors when invalid hex
strings like "not-a-color" and "also-not" are passed to
buildStreamingFadePalette. Iterate through the palette and assert that each
Style element has the expected fallback foreground instead of just verifying the
constant array length.
In `@internal/tui/streaming_fade.go`:
- Around line 229-231: The bounds check in the streaming_fade.go file (lines
229-231) returns a zero-valued time.Time when the visual index is out of range,
which causes wrapped middle lines to incorrectly snap to base ink. Instead of
returning time.Time{}, clamp the out-of-range visual index to the last available
logical age by returning the last element in the lineAges slice
(lineAges[len(lineAges)-1]) when the visual index is too large, ensuring wrapped
middle lines maintain consistent age-based fade behavior with neighboring lines.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro Plus
Run ID: d47969bf-e122-4e39-abbc-0819206cabea
📒 Files selected for processing (4)
internal/tui/model.gointernal/tui/spec_mode.gointernal/tui/streaming_fade.gointernal/tui/streaming_fade_test.go
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).
Resolves the v2 lipgloss migration conflict on the streaming-text fade feature. - Migrate streaming_fade.go to charm.land/lipgloss/v2 (was v1 github.com/charmbracelet/lipgloss). - Replace the manual linear-sRGB hexToRGB loop with lipgloss.Blend1D, which interpolates in CIELAB via go-colorful. Equivalent cost, better visual quality for any wider ramp in the future, and the go-colorful dependency is already pulled in by v2. - Convert Blend1D's colorful.Color output to color.RGBA at the builder so per-bucket style foregrounds are a stable stdlib type and tests can type-assert on RGBA without depending on go-colorful. - Update streaming_fade_test.go for v2: type assertions against color.RGBA instead of lipgloss.Color, byte-level comparisons instead of hex string comparisons. Build + vet + full TUI test suite are green. Co-Authored-By: Claude <noreply@anthropic.com>
gnanam1990
left a comment
There was a problem hiding this comment.
Approving — well-isolated, genuinely well-tested (palette monotonicity, bucket math, the visual→logical age mapping, and the defensive fallbacks are all covered), and green on every platform. The decoupled 150 ms fade tick that self-perpetuates while fadeActive and short-circuits at stream end is a clean lifecycle. A few non-blocking nits:
- Dead method:
ensureAgeTickReschedule(streaming_fade.go) has no callers — the tick is started inline in theagentTextMsgbranch and rescheduled in thestreamingFadeTickMsgcase. Safe to delete. - Spec-impl path never starts the dedicated fade tick.
approveSpecReviewsetsm.fadeActive = truebefore the first delta, so in theagentTextMsgbranchstartTick := !m.fadeActiveisfalseandstreamingFadeTick()is never scheduled. The fade then rides on the 80 ms spinner re-renders (batched there) instead of the 150 ms fade ticker — it works, but not via the mechanism the comment describes, and it would freeze if the spinner ever paused mid-stream. Simplest fix: drop them.fadeActive = trueand let the first delta start the tick exactly likelaunchPromptdoes (or addstreamingFadeTick()to the batch). - Description vs. code: the body says the ramp is interpolated "in linear sRGB" and that this "saves the go-colorful dependency," but the code uses
lipgloss.Blend1D(CIELAB, which pulls go-colorful in via lipgloss). No new direct dependency, but the description is out of sync with the implementation — worth a quick edit so future readers aren't misled. - Documented approximation (fine):
streamingLineBornAtmaps visual→logical lines 1:1, so once a logical line soft-wraps, later lines' ages drift by the wrap count. Imperceptible for a 1.2 s prose fade, and the last visual line always useslastActivity, so it degrades gracefully — just flagging it as a real edge if the ramp ever gets slower or longer.
Nice bit of polish. 👍
Summary
Adds a per-line age-based color fade to the live streaming assistant text. While the reply streams in, the freshest line starts in the brand lime (#caff3f) and gradually settles to the standard off-white ink (#ececee) over ~1.2 seconds. 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.
Why per-line, not per-rune? ~10x cheaper, visually equivalent for streaming prose, and survives soft-wrap without grapheme-segmentation code. The per-rune upgrade path is mechanical: replace
lineAges []time.TimewithcharAges []time.Timeand walk the last ~200 runes.Design
colorAccent(#caff3f) tocolorInk(#ececee). Pre-computed atinit()so per-frame lookups are a struct-field access, not a hex parse. Both endpoints are near-white, so the perceptual difference between sRGB and CIELAB is invisible — save the Lab math (and the go-colorful dependency) for the day someone complains.streamingFadeTickMsg, independent of the 80ms spinner tick. A slower cadence is enough for a smooth-feeling fade and keeps the per-frame work cheap.agentResponseMsgflipsfadeActive = false; the next tick that lands after that point short-circuits. No settle animation.WindowSizeMsgzeroslineAgesand re-stampslastStreamActivitybecause a width change can re-wrap the in-progress text into a different number of visual lines.m.streamingTextwithout going throughagentTextMsg(solineAgesis nil) render identically to before — the fade code short-circuits to the base ink color.lastStreamActivity, not the correspondinglineAgesentry. This is the rule that keeps the in-progress typing position visibly fresh.Spec-mode parity
The spec-impl run path calls
runAgentdirectly, bypassing the normallaunchPromptseed.approveSpecReviewnow seeds the fade state explicitly so spec-impl streaming text fades the same way regular runs do.Files
internal/tui/streaming_fade.go(~290 LoC): palette builder,streamingFadeTick,ageDimLine,streamingLineBornAt,recordStreamingDelta,resetStreamingFade,styleStreamingLine.internal/tui/streaming_fade_test.go(~205 LoC): 11 tests covering palette monotonicity, palette intermediates,ageDimLinefresh/mid-range/settled/zero-bornAt paths, bucket walk, tick command, delta newline tracking, first-entry seeding, reset,streamingLineBornAtlast-visual/middle/out-of-range, andstyleStreamingLinedefensive fallbacks.internal/tui/model.go(+~50 LoC): addlineAges,lastStreamActivity,fadeActivefields. HookagentTextMsg(record delta + schedule tick),agentResponseMsg(hard-stop),cancelRun(reset),WindowSizeMsg(reset). NewstreamingFadeTickMsgcase. Replace per-line style loop ininterimBlockwithm.styleStreamingLine.internal/tui/spec_mode.go(+7 LoC): seed the fade state inapproveSpecReviewso spec-impl streaming text fades.Test results
go test -count=1 -timeout 90s ./internal/tui/...→ 1113 tests, all green.go vet ./internal/tui/...→ clean.gofmt -l→ clean.(Pre-existing flake:
TestRunExecUsesProjectConfigAndOpenAICompatibleProviderininternal/clialso fails onmain; unrelated to this PR.)Manual verification
go run ./cmd/zero→ fire a long prompt ("explain the OAuth engine in this repo in detail") → new lines appear in brand lime and settle to off-white over ~1.2s, line by line. Mid-stream resize: all text snaps to fresh. Stream end: hard-stop to base color, no fade-out animation.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Tests