Skip to content

feat(tui): age-based fade for streaming assistant text#223

Merged
gnanam1990 merged 3 commits into
mainfrom
feat/tui-streaming-fade
Jun 16, 2026
Merged

feat(tui): age-based fade for streaming assistant text#223
gnanam1990 merged 3 commits into
mainfrom
feat/tui-streaming-fade

Conversation

@Vasanthdev2004

@Vasanthdev2004 Vasanthdev2004 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

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.Time with charAges []time.Time and walk the last ~200 runes.

Design

  • Palette: 12 steps, interpolated in linear sRGB from colorAccent (#caff3f) to colorInk (#ececee). Pre-computed at init() 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.
  • Cadence: 150ms streamingFadeTickMsg, independent of the 80ms spinner tick. A slower cadence is enough for a smooth-feeling fade and keeps the per-frame work cheap.
  • Hard stop at stream end: agentResponseMsg flips fadeActive = false; the next tick that lands after that point short-circuits. No settle animation.
  • Reset on resize: WindowSizeMsg zeros lineAges and re-stamps lastStreamActivity because a width change can re-wrap the in-progress text into a different number of visual lines.
  • Defensive on test fixtures: test models that pre-populate m.streamingText without going through agentTextMsg (so lineAges is nil) render identically to before — the fade code short-circuits to the base ink color.
  • The last visual line uses lastStreamActivity, not the corresponding lineAges entry. This is the rule that keeps the in-progress typing position visibly fresh.

Spec-mode parity

The spec-impl run path calls runAgent directly, bypassing the normal launchPrompt seed. approveSpecReview now seeds the fade state explicitly so spec-impl streaming text fades the same way regular runs do.

Files

  • NEW internal/tui/streaming_fade.go (~290 LoC): palette builder, streamingFadeTick, ageDimLine, streamingLineBornAt, recordStreamingDelta, resetStreamingFade, styleStreamingLine.
  • NEW internal/tui/streaming_fade_test.go (~205 LoC): 11 tests covering palette monotonicity, palette intermediates, ageDimLine fresh/mid-range/settled/zero-bornAt paths, bucket walk, tick command, delta newline tracking, first-entry seeding, reset, streamingLineBornAt last-visual/middle/out-of-range, and styleStreamingLine defensive fallbacks.
  • MODIFIED internal/tui/model.go (+~50 LoC): add lineAges, lastStreamActivity, fadeActive fields. Hook agentTextMsg (record delta + schedule tick), agentResponseMsg (hard-stop), cancelRun (reset), WindowSizeMsg (reset). New streamingFadeTickMsg case. Replace per-line style loop in interimBlock with m.styleStreamingLine.
  • MODIFIED internal/tui/spec_mode.go (+7 LoC): seed the fade state in approveSpecReview so 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: TestRunExecUsesProjectConfigAndOpenAICompatibleProvider in internal/cli also fails on main; 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

    • Added an age-based “fade” effect for streamed assistant text, using a fixed stepped color palette so newly added lines start highlighted and gradually settle into standard styling.
  • Bug Fixes

    • Improved fade initialization and lifecycle handling across streaming, including clean start/stop, proper behavior on resize, stream completion, and cancellation.
  • Tests

    • Expanded coverage for palette generation, line age bucketing/clamping, tick scheduling, newline age tracking, fade reset, and renderer fallback behavior.

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.
@Vasanthdev2004

Vasanthdev2004 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

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 (StreamingMarkdown keeps the stable prefix memoized, only the last block re-parses per delta) plus the dimColor prop on queued/executing tools. There's no per-rune ANSI interpolation in the streamed text itself.

The closest reference I found in a real Go TUI is charmbracelet/mods's anim.go, which uses a Luv-blend gradient for a cycling shimmer sidecar next to the typed text — not age decay on the text itself.

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:

  • Per-rune (most "alive"): replace lineAges []time.Time with charAges []time.Time, walk the last ~200 runes. ~3x the work, only matters when a single token takes >500ms to arrive.
  • Lab interpolation (visually more uniform): swap the linear sRGB math for lucasb-eyer/go-colorful's BlendLab. Saves a new dep; buys nothing visible for this ramp.
  • Settle animation at stream end (200ms ease-out to base color): adds a second ticker and a state. Nice-to-have, not v1.

Happy to follow up on any of these as separate PRs if useful.

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 6d312dd59673
Changed files (4): internal/tui/model.go, internal/tui/spec_mode.go, internal/tui/streaming_fade.go, internal/tui/streaming_fade_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e840408d-d54c-4ad5-9702-53391f5934ff

📥 Commits

Reviewing files that changed from the base of the PR and between bd9ad05 and 6d312dd.

📒 Files selected for processing (4)
  • internal/tui/model.go
  • internal/tui/spec_mode.go
  • internal/tui/streaming_fade.go
  • internal/tui/streaming_fade_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/tui/spec_mode.go
  • internal/tui/model.go

Walkthrough

Adds a timed "fade" effect for streamed assistant text in the TUI. A new streaming_fade.go module precomputes a 12-step accent→ink color palette, tracks per-line birth timestamps on each \n in streamed deltas, and drives periodic re-renders via a Bubble Tea tick. model.go integrates the fade state lifecycle across streaming, resize, completion, and cancellation paths. spec_mode.go seeds fade state before spec-implementation runs.

Changes

Streaming Fade Effect

Layer / File(s) Summary
Fade module: constants, palette, and color utilities
internal/tui/streaming_fade.go (lines 1–102)
Defines fade configuration (12-step buckets, 3-second duration, tick cadence) and streamingFadeTickMsg message type; precomputes accent→ink palette in init(); adds buildStreamingFadePalette to generate the full lipgloss style ramp via RGB interpolation with nil-color fallback.
Fade module: line-aging, birth-time tracking, and visual styling
internal/tui/streaming_fade.go (lines 104–257)
Implements streamingFadeTick for Bubble Tea tick scheduling; ageDimLine for bucket-based per-line coloring; streamingLineBornAt for visual→logical timestamp mapping; recordStreamingDelta for newline-driven age tracking; resetStreamingFade for state teardown; styleStreamingLine for conditional fade application; ensureAgeTickReschedule for safe tick rescheduling.
model.go state fields, message handlers, rendering, and lifecycle
internal/tui/model.go (lines 156–2418), internal/tui/spec_mode.go (lines 211–217)
Adds lineAges, lastStreamActivity, fadeActive state fields; wires agentTextMsg to recordStreamingDelta + tick start on inactive→active transition; handles streamingFadeTickMsg for periodic re-render; resets fade on tea.WindowSizeMsg, hard-stops on agentResponseMsg, cleans up on cancelRun; routes interimBlock lines through styleStreamingLine; initializes fade state in spec-mode before spec-implementation runAgent.
Unit tests for fade module and model integration
internal/tui/streaming_fade_test.go
Tests palette monotonicity, RGB bounds, length, and bad-hex fallback; ageDimLine at t=0/mid/post-duration/zero-age scenarios and bucket monotonicity; streamingFadeTick command construction; recordStreamingDelta newline tracking and first-entry seeding; resetStreamingFade clearing; streamingLineBornAt visual/logical/out-of-range and empty-age edge cases; styleStreamingLine fallback when fade is inactive or age data is nil.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.92% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main feature: an age-based fade effect for streaming assistant text in the TUI.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tui-streaming-fade

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between a0b9876 and dafbdf3.

📒 Files selected for processing (4)
  • internal/tui/model.go
  • internal/tui/spec_mode.go
  • internal/tui/streaming_fade.go
  • internal/tui/streaming_fade_test.go

Comment thread internal/tui/model.go
Comment thread internal/tui/model.go Outdated
Comment thread internal/tui/streaming_fade_test.go Outdated
Comment thread internal/tui/streaming_fade.go Outdated
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 gnanam1990 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the agentTextMsg branch and rescheduled in the streamingFadeTickMsg case. Safe to delete.
  • Spec-impl path never starts the dedicated fade tick. approveSpecReview sets m.fadeActive = true before the first delta, so in the agentTextMsg branch startTick := !m.fadeActive is false and streamingFadeTick() 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 the m.fadeActive = true and let the first delta start the tick exactly like launchPrompt does (or add streamingFadeTick() 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): streamingLineBornAt maps 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 uses lastActivity, so it degrades gracefully — just flagging it as a real edge if the ramp ever gets slower or longer.

Nice bit of polish. 👍

@gnanam1990
gnanam1990 merged commit 1e25fde into main Jun 16, 2026
7 checks passed
@Vasanthdev2004
Vasanthdev2004 deleted the feat/tui-streaming-fade branch June 28, 2026 08:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants