@@ -30,9 +30,14 @@ const (
3030 // change every 8 frames (400 milliseconds).
3131 ellipsisAnimSpeed = 8
3232
33− // The maximum amount of time that can pass before a character appears.
34− // This is used to create a staggered entrance effect.
35− maxBirthOffset = time.Second
33+ // The maximum number of animation steps that can pass before a
34+ // character appears. With fps == 20 this is ~1s of staggered
35+ // entrance, identical to the previous wall-clock-driven value.
36+ // Switching from wall-clock + rand to a step-driven birth schedule
37+ // keeps Render() deterministic: two Anim instances built from the
38+ // same Settings produce byte-identical output when no Animate ticks
39+ // have advanced their step counter.
40+ maxBirthSteps = 20
3641
3742 // Number of frames to prerender for the animation. After this number
3843 // of frames, the animation will loop. This only applies when color
@@ -107,12 +112,12 @@ type Anim struct {
107112 label *csync.Slice[string]
108113 labelWidth int
109114 labelColor color.Color
110− startTime time.Time
111− birthOffsets []time.Duration
115+ birthSteps []int
112116 initialFrames [][]string // frames for the initial characters
113117 initialized atomic.Bool
114118 cyclingFrames [][]string // frames for the cycling characters
115− step atomic.Int64 // current main frame step
119+ step atomic.Int64 // current main frame step (wraps)
120+ framesSinceStart atomic.Int64 // total Animate ticks (does not wrap)
116121 ellipsisStep atomic.Int64 // current ellipsis frame step
117122 ellipsisFrames *csync.Slice[string] // ellipsis animation frames
118123 id string
@@ -140,7 +145,6 @@ func New(opts Settings) *Anim {
140145 } else {
141146 a.id = fmt.Sprintf("%d", nextID())
142147 }
143− a.startTime = time.Now()
144148 a.cyclingCharWidth = opts.Size
145149 a.labelColor = opts.LabelColor
146150
@@ -207,7 +211,13 @@ func New(opts Settings) *Anim {
207211 }
208212 }
209213
210− // Prerender scrambled rune frames for the animation.
214+ // Prerender scrambled rune frames for the animation. Seed
215+ // the rune picker off the settings hash so cyclingFrames is
216+ // a pure function of Settings: two processes with identical
217+ // Settings populate the cache with the same glyphs, which
218+ // keeps any cross-process golden-file comparison stable.
219+ seed := xxh3.HashString(cacheKey)
220+ rng := rand.New(rand.NewPCG(seed, ^seed))
211221 a.cyclingFrames = make([][]string, numFrames)
212222 offset = 0
213223 for i := range a.cyclingFrames {
@@ -219,7 +229,7 @@ func New(opts Settings) *Anim {
219229
220230 // Also prerender the color with Lip Gloss here to avoid processing
221231 // in the render loop.
222− r := availableRunes[rand.IntN(len(availableRunes))]
232+ r := availableRunes[rng.IntN(len(availableRunes))]
223233 a.cyclingFrames[i][j] = lipgloss.NewStyle().
224234 Foreground(ramp[j+offset]).
225235 Render(string(r))
@@ -249,10 +259,20 @@ func New(opts Settings) *Anim {
249259 animCacheMap.Set(cacheKey, cached)
250260 }
251261
252− // Random assign a birth to each character for a stagged entrance effect.
253− a.birthOffsets = make([]time.Duration, a.width)
254− for i := range a.birthOffsets {
255− a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
262+ // Assign a deterministic birth step to each column for a
263+ // staggered entrance effect. The schedule is seeded off the
264+ // spinner id and the settings hash, so two spinners with the
265+ // same role and identity stagger identically (this is what
266+ // keeps Render() byte-equal across cache hits and across
267+ // processes for the same Settings+ID) while spinners with
268+ // different ids — distinct assistant messages, different tool
269+ // calls, "Thinking" vs "Generating" labels — fade in with
270+ // different patterns instead of marching in lock-step.
271+ birthSeed := xxh3.HashString(a.id + "|" + cacheKey)
272+ birthRng := rand.New(rand.NewPCG(birthSeed, ^birthSeed))
273+ a.birthSteps = make([]int, a.width)
274+ for i := range a.birthSteps {
275+ a.birthSteps[i] = birthRng.IntN(maxBirthSteps)
256276 }
257277
258278 return a
@@ -333,13 +353,14 @@ func (a *Anim) Animate(msg StepMsg) tea.Cmd {
333353 a.step.Store(0)
334354 }
335355
356+ frames := a.framesSinceStart.Add(1)
336357 if a.initialized.Load() && a.labelWidth > 0 {
337358 // Manage the ellipsis animation.
338359 ellipsisStep := a.ellipsisStep.Add(1)
339360 if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
340361 a.ellipsisStep.Store(0)
341362 }
342− } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
363+ } else if !a.initialized.Load() && int(frames) >= maxBirthSteps {
343364 a.initialized.Store(true)
344365 }
345366 return a.Step()
@@ -349,10 +370,11 @@ func (a *Anim) Animate(msg StepMsg) tea.Cmd {
349370func (a *Anim) Render() string {
350371 var b strings.Builder
351372 step := int(a.step.Load())
373+ frames := int(a.framesSinceStart.Load())
352374 for i := range a.width {
353375 switch {
354− case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
355− // Birth offset not reached: render initial character.
376+ case !a.initialized.Load() && i < len(a.birthSteps) && frames < a.birthSteps[i]:
377+ // Birth step not reached: render initial character.
356378 b.WriteString(a.initialFrames[step][i])
357379 case i < a.cyclingCharWidth:
358380 // Render a cycling character.