pi-agent-flow 1.8.38 → 1.8.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/scramble.js CHANGED
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Mode 4 — ILLUMINATE: Neon glow ripple with depth-based esoteric char sets,
17
17
  * ANSI truecolor, phrase-chunked msg streaming, and TPS hysteresis.
18
- * Per-target color configs (cyan aim, purple act, gold TPS, etc.).
18
+ * Per-target color configs (sky aim, warm act, peach TPS, etc.).
19
19
  *
20
20
  * Line behavior (all modes):
21
21
  * aim: — content stays still, no animation ever
@@ -70,16 +70,22 @@ export function hashNoise(seed, charIndex, tick, depth) {
70
70
  // ---------------------------------------------------------------------------
71
71
  // Character sets — depth-based esoteric scramble symbols (illuminate mode)
72
72
  // ---------------------------------------------------------------------------
73
- /** Deep glitch: fine dots, braille, ASCII punctuation for inner ripple depths (1–2) */
74
- const DEEP_GLITCH = '·∘∙+*~!?⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓';
75
- /** Mid glitch: dots, braille, light ASCII for mid depth (3) */
76
- const MID_GLITCH = '·∘∙⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋~?+-*';
77
- /** Shallow glitch: numbers/brackets + shade blocks + light box-drawing for outer depths (4+) */
78
- const SHALLOW_GLITCH = '·∘∙⠁⠂⠃⠄⠅⠆~?+-';
79
- /** Classic ASCII-safe set for stream/cascade/ripple fallback */
80
- const SCRAMBLE_CHARS = '·∘∙~?+-*/[]{}<>_○◎';
81
- /** Thin braille spark: single-dot and sparse two-dot patterns for afterglow "pop" */
82
- const THIN_BRAILLE_SPARK = '⠂⠄⠈⠐⠠⡀⢀⠃⠆⠉⠘⠰⡁⢂';
73
+ /** Deep glitch: fine dots, sparse sparkle, dense braille for inner ripple depths (1–2) */
74
+ const DEEP_GLITCH = '·∘∙*˚。⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓';
75
+ /** Mid glitch: dots, light sparkles, medium braille for depth (3) */
76
+ const MID_GLITCH = '·∘∙~⋆˚。+×◇°⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋';
77
+ /** Shallow glitch: heavy sparkles + light braille for outer depths (4+) — the wavefront crest */
78
+ const SHALLOW_GLITCH = '·∘∙~×°+⠌⠡⠜';
79
+ /** Classic scramble set for stream/cascade/ripple fallback — balanced braille + sparkle mix */
80
+ const SCRAMBLE_CHARS = '·∘∙~⋆˚。+×◇°⠌⠡⠜⠣⠪⠹⠸⠷⠮⠯⠿⠾';
81
+ /** Sparkle and thin braille mix for afterglow "pop" */
82
+ const SPARK_CHARS = '·∘∙⋆˚。⠂⠄⠈⠐⠠⡀⢀⠃⠆⠉⠘⠰⡁⢂';
83
+ /** Backward-compat alias */
84
+ const THIN_BRAILLE_SPARK = SPARK_CHARS;
85
+ const DECORATIVE_ICON_RE = /[✔✅✖❌◐✓]/g;
86
+ function stripDecorativeIcons(text) {
87
+ return text.replace(DECORATIVE_ICON_RE, '');
88
+ }
83
89
  function selectScrambleChar(depth, dist, elapsed, seed, textLen) {
84
90
  const tickMs = (textLen !== undefined && textLen < 20) ? 300 : 150;
85
91
  const tick = Math.floor(elapsed / tickMs);
@@ -130,38 +136,38 @@ function selectSparkChar(seed, charIndex, tick) {
130
136
  // ANSI truecolor neon glow constants (illuminate mode)
131
137
  // ---------------------------------------------------------------------------
132
138
  const CYAN_GLOW = '\x1b[38;2;0;255;204m';
133
- const WARM_GLOW = '\x1b[38;2;251;176;169m';
134
- const PEACH_GLOW = '\x1b[38;2;251;200;193m';
139
+ const WARM_GLOW = '\x1b[38;2;255;140;120m';
140
+ const PEACH_GLOW = '\x1b[38;2;255;160;140m';
135
141
  const ORANGE_GLOW = '\x1b[38;2;255;190;130m';
136
- const SKY_GLOW = '\x1b[38;2;152;203;250m';
142
+ const SKY_GLOW = '\x1b[38;2;80;170;255m';
137
143
  const WHITE_GLOW = '\x1b[38;2;255;255;255m';
138
144
  const RESET_COLOR = '\x1b[39m';
139
145
  const BOLD_ON = '\x1b[1m';
140
146
  const BOLD_OFF = '\x1b[22m';
141
147
  const DIM_ON = '\x1b[2m';
142
148
  const DIM_OFF = '\x1b[22m';
143
- /** Illuminate close: turns off bold (SGR 22 also kills dim), then re-applies
144
- * dim (SGR 2) so enclosing dim context survives scramble transitions. */
149
+ /** Illuminate close: resets foreground color only. No bg or bold/dim resets
150
+ * needed bold is never applied, and enclosing dim context is preserved. */
145
151
  const ILLUMINATE_CLOSE = '\x1b[39m';
146
152
  const ILLUMINATE_CONFIGS = {
147
- aimLabel: { color: CYAN_GLOW, duration: 240, spread: 1.0, glowIntensity: 'high', crestOnly: true, spark: false },
148
- actLabel: { color: WARM_GLOW, duration: 240, spread: 1.0, glowIntensity: 'high', crestOnly: true, spark: false },
149
- msgLabel: { color: PEACH_GLOW, duration: 240, spread: 1.0, glowIntensity: 'high', crestOnly: true, spark: false },
150
- msgContent: { color: 'dynamic', duration: 400, spread: 1.0, glowIntensity: 'variable', initialTimeOffset: 30 },
151
- flowMeta: { color: WARM_GLOW, duration: 250, spread: 0.8, glowIntensity: 'medium', crestOnly: true, spark: false },
152
- tps: { color: ORANGE_GLOW, duration: 84, spread: 0.5, glowIntensity: 'medium', crestOnly: true, spark: false },
153
+ aimLabel: { color: SKY_GLOW, duration: 360, spread: 1.0, glowIntensity: 'high', crestOnly: false, spark: false },
154
+ actLabel: { color: WARM_GLOW, duration: 360, spread: 1.0, glowIntensity: 'high', crestOnly: false, spark: false },
155
+ msgLabel: { color: PEACH_GLOW, duration: 360, spread: 1.0, glowIntensity: 'high', crestOnly: false, spark: false },
156
+ msgContent: { color: 'dynamic', duration: 600, spread: 1.0, glowIntensity: 'variable', initialTimeOffset: 30, scramble: false },
157
+ flowMeta: { color: WARM_GLOW, duration: 380, spread: 0.8, glowIntensity: 'medium', crestOnly: false, spark: false },
158
+ tps: { color: WARM_GLOW, duration: 84, spread: 0.5, glowIntensity: 'medium', crestOnly: true, spark: false },
153
159
  };
154
160
  // ---------------------------------------------------------------------------
155
161
  // Timing constants
156
162
  // ---------------------------------------------------------------------------
157
- const RIPPLE_DUR_DEFAULT = 340;
163
+ const RIPPLE_DUR_DEFAULT = 520;
158
164
  const RIPPLE_SPREAD_DEFAULT = 1;
159
- const MIN_RIPPLE_INTERVAL = 420;
165
+ const MIN_RIPPLE_INTERVAL = 300;
160
166
  const DEPTH_BAND_MAX = 7;
161
167
  const TPS_FLASH_DUR = 105;
162
168
  const TPS_FLASH_SPREAD = 0.5;
163
- const AFTERGLOW_MS = 300;
164
- const ECHO_AFTERGLOW_MS = 500;
169
+ const AFTERGLOW_MS = 420;
170
+ const ECHO_AFTERGLOW_MS = 650;
165
171
  const FLASH_AFTERGLOW_MS = 137; // shorter afterglow for TPS/KPI value flashes
166
172
  const PULSE_WINDOW_MS = 600;
167
173
  const PULSE_CYCLE_MS = 998;
@@ -176,7 +182,7 @@ const MIN_PHRASE_LENGTH = 60;
176
182
  // Drain timeout: partial chunk ripples when text stops changing for this long.
177
183
  // Tokens arrive ~200ms apart at 196 TPS; 350ms is long enough to avoid firing
178
184
  // during active streaming but short enough to feel responsive when tool calls pause.
179
- const MSG_CHUNK_DRAIN_MS = 245;
185
+ const MSG_CHUNK_DRAIN_MS = 120;
180
186
  // Resume gap: after a long pause (e.g. tool call), treat resumed chunks as a
181
187
  // fresh stream and force a ripple effect.
182
188
  const STREAMING_RESUME_GAP_MS = 2000;
@@ -192,6 +198,13 @@ const STREAM_SPEED_MSG = 35; // ms per char for msg: (~29 chars/sec)
192
198
  const STREAM_SPEED_ACT = 25; // ms per char for act: (~40 chars/sec)
193
199
  const STREAM_SCRAMBLE_WIDTH = 5; // scramble chars at cursor position
194
200
  const STREAM_RERANDOMIZE_RATE = 0.28; // 28% chance to re-randomize (CodePen style)
201
+ const GLITCH_RERANDOMIZE = 0.28;
202
+ const GLITCH_MAX_START = 40;
203
+ const GLITCH_MAX_LENGTH = 40;
204
+ const GLITCH_SHORT_MAX_START = 10;
205
+ const GLITCH_SHORT_MAX_LENGTH = 10;
206
+ const GLITCH_COOLDOWN_MS = 1000;
207
+ const GLITCH_FADE_OUT_FRAMES = 18;
195
208
  // ---------------------------------------------------------------------------
196
209
  // Easing and interpolation helpers
197
210
  // ---------------------------------------------------------------------------
@@ -275,7 +288,13 @@ function shouldFlushPhrase(text, displayed, lastFlushTime, now) {
275
288
  newContent = text;
276
289
  }
277
290
  const boundaryPos = findPhraseBoundary(newContent);
278
- return boundaryPos >= 0;
291
+ if (boundaryPos >= 0)
292
+ return true;
293
+ // Force flush: if enough new content accumulated, flush regardless of boundary
294
+ const newContentLen = text.startsWith(displayed) ? text.length - displayed.length : text.length;
295
+ if (newContentLen >= 40)
296
+ return true;
297
+ return false;
279
298
  }
280
299
  // ---------------------------------------------------------------------------
281
300
  // Pure helpers
@@ -381,7 +400,9 @@ export function renderStreamText(visibleText, visibleRevealed, scrambleWidth, cu
381
400
  // ---------------------------------------------------------------------------
382
401
  export function buildQueue(oldText, newText, maxStart = CASCADE_MAX_START, maxLength = CASCADE_MAX_LENGTH, rng) {
383
402
  const queue = [];
384
- const length = Math.max(oldText.length, newText.length);
403
+ const cleanOld = stripDecorativeIcons(oldText);
404
+ const cleanNew = stripDecorativeIcons(newText);
405
+ const length = Math.max(cleanOld.length, cleanNew.length);
385
406
  const useRng = rng ?? new FastRNG(makeAnimationSeed(newText, Date.now()));
386
407
  for (let i = 0; i < length; i++) {
387
408
  const from = oldText[i] || '';
@@ -446,6 +467,79 @@ export function computeCascadeFrame(queue, frame, rng) {
446
467
  result += DIM_OFF;
447
468
  return result;
448
469
  }
470
+ // ---------------------------------------------------------------------------
471
+ // Pure algorithm: GLITCH (TextScramble faithful port with Unicode braille)
472
+ // ---------------------------------------------------------------------------
473
+ export function buildGlitchQueue(oldText, newText, maxStart = GLITCH_MAX_START, maxLength = GLITCH_MAX_LENGTH) {
474
+ const queue = [];
475
+ const cleanOld = stripDecorativeIcons(oldText);
476
+ const cleanNew = stripDecorativeIcons(newText);
477
+ const length = Math.max(cleanOld.length, cleanNew.length);
478
+ for (let i = 0; i < length; i++) {
479
+ const from = cleanOld[i] || '';
480
+ const to = cleanNew[i] || '';
481
+ const start = Math.floor(Math.random() * maxStart);
482
+ const end = start + Math.floor(Math.random() * maxLength);
483
+ const fadeOutEnd = to === '' ? end + GLITCH_FADE_OUT_FRAMES : undefined;
484
+ queue.push({ from, to, start, end, fadeOutEnd, char: null });
485
+ }
486
+ return queue;
487
+ }
488
+ export function computeGlitchFrame(queue, frame, rng) {
489
+ let output = '';
490
+ let inDim = false;
491
+ for (let i = 0; i < queue.length; i++) {
492
+ const entry = queue[i];
493
+ const fadeOutEnd = entry.fadeOutEnd;
494
+ if (fadeOutEnd !== undefined && frame >= entry.end && frame < fadeOutEnd) {
495
+ if (!inDim) {
496
+ output += DIM_ON;
497
+ inDim = true;
498
+ }
499
+ if (!entry.char || Math.random() < GLITCH_RERANDOMIZE) {
500
+ entry.char = rng();
501
+ }
502
+ output += entry.char;
503
+ }
504
+ else if (frame >= (fadeOutEnd ?? entry.end)) {
505
+ if (inDim) {
506
+ output += DIM_OFF;
507
+ inDim = false;
508
+ }
509
+ output += entry.to;
510
+ }
511
+ else if (frame >= entry.start) {
512
+ if (inDim) {
513
+ output += DIM_OFF;
514
+ inDim = false;
515
+ }
516
+ if (!entry.char || Math.random() < GLITCH_RERANDOMIZE) {
517
+ entry.char = rng();
518
+ }
519
+ output += entry.char;
520
+ }
521
+ else {
522
+ if (inDim) {
523
+ output += DIM_OFF;
524
+ inDim = false;
525
+ }
526
+ output += entry.from;
527
+ }
528
+ }
529
+ if (inDim)
530
+ output += DIM_OFF;
531
+ return output;
532
+ }
533
+ export function isGlitchComplete(queue, frame) {
534
+ if (queue.length === 0)
535
+ return true;
536
+ return frame >= Math.max(...queue.map(e => e.fadeOutEnd ?? e.end));
537
+ }
538
+ function shouldStartGlitch(state, now, cooldownMs) {
539
+ if (state.glitchQueue.length > 0)
540
+ return false; // already animating
541
+ return now - state.lastGlitchTime >= cooldownMs;
542
+ }
449
543
  function isCascadeComplete(queue, frame, maxEnd) {
450
544
  const clampedFrame = Math.max(0, frame);
451
545
  if (maxEnd !== undefined)
@@ -467,68 +561,47 @@ function illuminatePrefix(depth, elapsed, dur, config, combinedDepth) {
467
561
  const heat = Math.min(1, depth / DEPTH_BAND_MAX);
468
562
  const life = 1 - progress;
469
563
  const intensity = heat * life * (1 - 0.25 * heat);
470
- // 8-zone continuous truecolor gradient: cyanmagentawarm → peach → sky → white
471
- // with smooth sub-zone blends for liquid, band-free transitions
564
+ // 5-zone continuous truecolor gradient: deep sky bright sky sky-peach bridge vivid peach → rich salmon warm white peak
472
565
  let r, g, b;
473
- if (intensity < 0.18) {
474
- const t = smoothstep(0, 0.18, intensity);
475
- r = lerp(0, 125, t);
476
- g = lerp(230, 177, t);
477
- b = lerp(220, 172, t);
478
- }
479
- else if (intensity < 0.30) {
480
- const t = smoothstep(0.18, 0.30, intensity);
481
- r = lerp(125, 236, t);
482
- g = lerp(177, 72, t);
483
- b = lerp(172, 153, t);
484
- }
485
- else if (intensity < 0.42) {
486
- const t = smoothstep(0.30, 0.42, intensity);
487
- r = lerp(236, 251, t);
488
- g = lerp(72, 176, t);
489
- b = lerp(153, 169, t);
490
- }
491
- else if (intensity < 0.58) {
492
- const t = smoothstep(0.42, 0.58, intensity);
493
- r = lerp(251, 250, t);
494
- g = lerp(176, 190, t);
495
- b = lerp(169, 183, t);
496
- }
497
- else if (intensity < 0.74) {
498
- const t = smoothstep(0.58, 0.74, intensity);
499
- r = lerp(250, 200, t);
500
- g = lerp(190, 200, t);
501
- b = lerp(183, 210, t);
502
- }
503
- else if (intensity < 0.88) {
504
- const t = smoothstep(0.74, 0.88, intensity);
505
- r = lerp(200, 152, t);
506
- g = lerp(200, 203, t);
507
- b = lerp(210, 250, t);
508
- }
509
- else if (intensity < 0.96) {
510
- const t = smoothstep(0.88, 0.96, intensity);
511
- r = lerp(152, 0, t);
512
- g = lerp(203, 230, t);
513
- b = lerp(250, 220, t);
566
+ if (intensity < 0.20) {
567
+ const t = smoothstep(0, 0.20, intensity);
568
+ r = lerp(0, 80, t);
569
+ g = lerp(80, 170, t);
570
+ b = lerp(255, 255, t);
571
+ }
572
+ else if (intensity < 0.40) {
573
+ const t = smoothstep(0.20, 0.40, intensity);
574
+ r = lerp(80, 180, t);
575
+ g = lerp(170, 170, t);
576
+ b = lerp(255, 210, t);
577
+ }
578
+ else if (intensity < 0.60) {
579
+ const t = smoothstep(0.40, 0.60, intensity);
580
+ r = lerp(180, 255, t);
581
+ g = lerp(170, 140, t);
582
+ b = lerp(210, 120, t);
583
+ }
584
+ else if (intensity < 0.80) {
585
+ const t = smoothstep(0.60, 0.80, intensity);
586
+ r = lerp(255, 255, t);
587
+ g = lerp(140, 90, t);
588
+ b = lerp(120, 70, t);
514
589
  }
515
590
  else {
516
- const t = smoothstep(0.96, 1.0, intensity);
517
- r = lerp(0, 0, t);
518
- g = lerp(230, 230, t);
519
- b = lerp(220, 220, t);
591
+ const t = smoothstep(0.80, 1.0, intensity);
592
+ r = lerp(255, 255, t);
593
+ g = lerp(90, 240, t);
594
+ b = lerp(70, 230, t);
520
595
  }
521
- // Interference boost: overlapping ripples create prismatic refraction
596
+ // Interference boost: overlapping ripples warm-white flash
522
597
  const effectiveCombined = combinedDepth ?? depth;
523
598
  const interferenceBoost = Math.max(0, (effectiveCombined - DEPTH_BAND_MAX * 0.6) / DEPTH_BAND_MAX);
524
599
  if (interferenceBoost > 0) {
525
- // Warm→cool cyan flash for prismatic refraction
526
- const targetR = 50, targetG = 255, targetB = 255;
600
+ const targetR = 255, targetG = 245, targetB = 240;
527
601
  r = Math.min(255, Math.max(0, Math.round(r + interferenceBoost * (targetR - r))));
528
602
  g = Math.min(255, Math.max(0, Math.round(g + interferenceBoost * (targetG - g))));
529
603
  b = Math.min(255, Math.max(0, Math.round(b + interferenceBoost * (targetB - b))));
530
604
  }
531
- // No DIM/BOLD — truecolor RGB handles brightness smoothly
532
605
  return `\x1b[38;2;${r};${g};${b}m`;
533
606
  }
534
607
  return config.color;
@@ -628,7 +701,7 @@ export function applyRipples(text, ripples, now, config, targetText, resolvedMas
628
701
  const jitterTick = Math.floor(now / 42);
629
702
  const depthJitter = (hashNoise(seed, bestDist, jitterTick, 99) * 2 - 1) * 0.15;
630
703
  const jitteredDepth = Math.max(0.1, maxDepth + depthJitter);
631
- const char = selectScrambleChar(jitteredDepth, bestDist, bestElapsed, seed, text.length);
704
+ const char = (config?.scramble === false) ? origChar : selectScrambleChar(jitteredDepth, bestDist, bestElapsed, seed, text.length);
632
705
  if (config) {
633
706
  const crestDepth = radii[bestIdx] - bestDist;
634
707
  const isCrest = !config.crestOnly || (crestDepth > 0 && crestDepth < 2.0);
@@ -636,13 +709,12 @@ export function applyRipples(text, ripples, now, config, targetText, resolvedMas
636
709
  if (isCrest) {
637
710
  prefix = illuminatePrefix(maxDepth, bestElapsed, bestDur, config, combinedDepth);
638
711
  if (config.color === 'dynamic' && crestDepth > 0 && crestDepth < 1.5) {
639
- // Alternate cyan/orange at crest based on character position, no bold
640
- if (bestDist % 2 === 0) {
641
- prefix = '\x1b[38;2;0;230;220m'; // cyan
642
- }
643
- else {
644
- prefix = '\x1b[38;2;255;165;50m'; // orange
645
- }
712
+ // Gradient peak: vivid salmon warm white
713
+ const t = Math.min(1, crestDepth / 1.5);
714
+ const cr = Math.round(lerp(255, 255, t));
715
+ const cg = Math.round(lerp(90, 240, t));
716
+ const cb = Math.round(lerp(70, 230, t));
717
+ prefix = `\x1b[38;2;${cr};${cg};${cb}m`;
646
718
  }
647
719
  }
648
720
  if (prefix) {
@@ -683,7 +755,7 @@ export function applyRipples(text, ripples, now, config, targetText, resolvedMas
683
755
  const glitchRoll = bestAgIdx >= 0 ? hashNoise(agRipple.seed ?? 0, idx, agTick, 77) : 1;
684
756
  const popTarget = Math.min(0.045, 4 / Math.max(1, text.length));
685
757
  const shouldScramble = inInitialPopWindow && bestAgIdx >= 0 && afterglowRipples[bestAgIdx].dur >= 210 && glitchRoll < popTarget;
686
- if (shouldScramble) {
758
+ if (shouldScramble && config?.scramble !== false) {
687
759
  if (config) {
688
760
  let agPrefix;
689
761
  if (config.color === 'dynamic') {
@@ -691,8 +763,8 @@ export function applyRipples(text, ripples, now, config, targetText, resolvedMas
691
763
  // Echo pops get minimum intensity so chars stay visible long after ripple
692
764
  const effectiveIntensity = afterglowIntensity;
693
765
  const emberR = Math.round(200 + 55 * effectiveIntensity);
694
- const emberG = Math.round(100 + 60 * effectiveIntensity);
695
- const emberB = Math.round(40 + 20 * effectiveIntensity);
766
+ const emberG = Math.round(130 + 80 * effectiveIntensity);
767
+ const emberB = Math.round(140 + 70 * effectiveIntensity);
696
768
  agPrefix = `\x1b[38;2;${emberR};${emberG};${emberB}m`;
697
769
  }
698
770
  else {
@@ -735,8 +807,8 @@ export function applyRipples(text, ripples, now, config, targetText, resolvedMas
735
807
  const settleRoll = hashNoise(42, idx, settleTick, 33);
736
808
  if (settleRoll < 0.05) {
737
809
  const settlePrefix = (hashNoise(42, idx, settleTick, 55) < 0.5)
738
- ? '\x1b[38;2;0;200;195m' // cyan
739
- : '\x1b[38;2;230;140;40m'; // orange
810
+ ? '\x1b[38;2;80;170;255m' // sky
811
+ : '\x1b[38;2;255;140;120m'; // warm
740
812
  if (!inColor || currentPrefix !== settlePrefix) {
741
813
  if (inColor)
742
814
  segments[segCount++] = ILLUMINATE_CLOSE;
@@ -764,9 +836,9 @@ function spawnIlluminateRipple(pos, now, config, seed, contentChange) {
764
836
  }
765
837
  function getRippleDuration(textLength, baseDur = RIPPLE_DUR_DEFAULT) {
766
838
  if (textLength <= 5)
767
- return Math.max(baseDur, 730);
839
+ return Math.max(baseDur, 950);
768
840
  if (textLength <= 10)
769
- return Math.max(baseDur, 645);
841
+ return Math.max(baseDur, 850);
770
842
  return baseDur;
771
843
  }
772
844
  function spawnSecondaryRipple(primary) {
@@ -791,34 +863,20 @@ function spawnIlluminateRippleForText(pos, now, config, textLength, seed, conten
791
863
  const primary = spawnIlluminateRipple(pos, now, { ...config, duration: dur }, seed, contentChange);
792
864
  return [primary, spawnSecondaryRipple(primary)];
793
865
  }
794
- function spawnTpsRipples(pos, now) {
795
- // TPS flash is intentionally brief — no secondary ripple
796
- return [spawnRipple(pos, now, TPS_FLASH_DUR, TPS_FLASH_SPREAD)];
797
- }
798
- function spawnTpsIlluminateRipples(pos, now) {
799
- // TPS flash is intentionally brief — no secondary ripple
800
- return [spawnIlluminateRipple(pos, now, ILLUMINATE_CONFIGS.tps)];
801
- }
802
866
  /**
803
867
  * Compute a ripple spawn center with random jitter.
804
- * The position is anchored at the text center but randomized by up to
805
- * `jitterRatio` of the text length (default ±20%), clamped to [0, len-1].
868
+ * The position is chosen uniformly between 20% and 80% of the text
869
+ * length (or the center for very short strings), giving a varied
870
+ * but never edge-clamped ripple origin.
806
871
  */
807
872
  function randomizedCenter(length, jitterRatio, rng) {
808
- const base = Math.floor(length / 2);
809
- if (length <= 1)
810
- return base;
811
- const effectiveRatio = jitterRatio ?? (length < 20 ? 0.4 : 0.2);
812
- // Cap jitter so center never lands at the very edge for short texts,
813
- // preserving wavefront symmetry.
814
- const rawJitter = Math.floor(length * effectiveRatio);
815
- const maxJitter = Math.max(0, Math.min(rawJitter, base - 1, length - base - 2));
816
- if (maxJitter <= 0)
817
- return base;
818
- const offset = rng
819
- ? rng.nextInt(maxJitter * 2 + 1) - maxJitter
820
- : Math.floor(Math.random() * (maxJitter * 2 + 1)) - maxJitter;
821
- return base + offset;
873
+ const min = Math.max(0, Math.floor(length * 0.2));
874
+ const max = Math.min(length - 1, Math.floor(length * 0.8));
875
+ if (max <= min)
876
+ return Math.floor(length / 2);
877
+ const range = max - min + 1;
878
+ const offset = rng ? rng.nextInt(range) : Math.floor(Math.random() * range);
879
+ return min + offset;
822
880
  }
823
881
  /**
824
882
  * Find sentence-start character positions in text.
@@ -916,6 +974,15 @@ function applyScramble(text, state, now, mode, lineKey, rng) {
916
974
  return computeCascadeFrame(state.queue, frame, rng);
917
975
  }
918
976
  else if (mode === 'illuminate') {
977
+ if (state.glitchQueue.length > 0) {
978
+ const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
979
+ if (isGlitchComplete(state.glitchQueue, frame)) {
980
+ state.glitchQueue = [];
981
+ state.glitchFrame = 0;
982
+ return text;
983
+ }
984
+ return computeGlitchFrame(state.glitchQueue, frame, rng ?? poolRandomChar);
985
+ }
919
986
  const config = lineKey === 'msg'
920
987
  ? ILLUMINATE_CONFIGS.msgContent
921
988
  : lineKey === 'act'
@@ -965,74 +1032,111 @@ function processLine(state, newText, now, mode, lineKey) {
965
1032
  state.ripples.length = keep;
966
1033
  const hasActiveRipples = state.ripples.some(r => now - r.time < r.dur);
967
1034
  const gap = now - state.lastTextChangeTime;
1035
+ const glitchCooledDown = now - state.lastGlitchTime >= GLITCH_COOLDOWN_MS;
1036
+ const previousText = state.lastText;
968
1037
  if (textChanged) {
1038
+ const delta = Math.max(0, newText.length - state.lastText.length);
969
1039
  state.lastText = newText;
970
1040
  state.phraseBuffer = newText;
971
1041
  state.lastTextChangeTime = now;
972
- // During active ripple, keep displayedText frozen (text being scrambled)
973
- // Between ripples, displayedText stays as last rippled text for chunk detection
1042
+ state.charsSinceLastFlush += delta;
974
1043
  }
975
- // If no active ripple and accumulated content meets chunk threshold → fire ripple
976
- if (!hasActiveRipples && shouldFlushPhrase(newText, state.displayedText, state.lastFlushTime, now)) {
1044
+ // F1: accumulator periodic ripples during dense streaming
1045
+ if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && state.charsSinceLastFlush >= 20 && newText !== state.displayedText) {
1046
+ const oldDisplayed = previousText || state.displayedText;
977
1047
  state.displayedText = newText;
978
1048
  state.lastFlushTime = now;
979
1049
  state.lastAnimTime = now;
980
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(newText.length), now, ILLUMINATE_CONFIGS.msgContent, newText.length, undefined, true));
1050
+ state.charsSinceLastFlush = 0;
1051
+ state.ripples = [];
1052
+ if (glitchCooledDown) {
1053
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, newText);
1054
+ state.startTime = now;
1055
+ state.glitchFrame = 0;
1056
+ state.lastGlitchTime = now;
1057
+ }
981
1058
  }
982
- else if (!hasActiveRipples && newText !== state.displayedText && now - state.lastTextChangeTime > MSG_CHUNK_DRAIN_MS) {
1059
+ else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && shouldFlushPhrase(newText, state.displayedText, state.lastFlushTime, now)) {
1060
+ const oldDisplayed = previousText || state.displayedText;
1061
+ state.displayedText = newText;
1062
+ state.lastFlushTime = now;
1063
+ state.lastAnimTime = now;
1064
+ state.charsSinceLastFlush = 0;
1065
+ state.ripples = [];
1066
+ if (glitchCooledDown) {
1067
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, newText);
1068
+ state.startTime = now;
1069
+ state.glitchFrame = 0;
1070
+ state.lastGlitchTime = now;
1071
+ }
1072
+ }
1073
+ else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && newText !== state.displayedText && now - state.lastTextChangeTime > MSG_CHUNK_DRAIN_MS) {
983
1074
  // Drain: text stopped arriving and we have unrippled content —
984
- // ripple it out so it doesn't sit plain indefinitely.
1075
+ // glitch it out so it doesn't sit plain indefinitely.
1076
+ const oldDisplayed = previousText || state.displayedText;
985
1077
  state.displayedText = newText;
986
1078
  state.lastFlushTime = now;
987
1079
  state.lastAnimTime = now;
988
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(newText.length), now, ILLUMINATE_CONFIGS.msgContent, newText.length, undefined, true));
1080
+ state.charsSinceLastFlush = 0;
1081
+ state.ripples = [];
1082
+ if (glitchCooledDown) {
1083
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, newText);
1084
+ state.startTime = now;
1085
+ state.glitchFrame = 0;
1086
+ state.lastGlitchTime = now;
1087
+ }
989
1088
  }
990
- else if (!hasActiveRipples && newText !== state.displayedText && gap > STREAMING_RESUME_GAP_MS) {
1089
+ else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && newText !== state.displayedText && gap > STREAMING_RESUME_GAP_MS) {
991
1090
  // Streaming resumed after a long pause (e.g., tool call) —
992
- // force a fresh ripple on the accumulated content.
1091
+ // force a fresh glitch on the accumulated content.
1092
+ const oldDisplayed = previousText || state.displayedText;
993
1093
  state.displayedText = newText;
994
1094
  state.lastFlushTime = now;
995
1095
  state.lastAnimTime = now;
996
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(newText.length), now, ILLUMINATE_CONFIGS.msgContent, newText.length, undefined, true));
1096
+ state.charsSinceLastFlush = 0;
1097
+ state.ripples = [];
1098
+ if (glitchCooledDown) {
1099
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, newText);
1100
+ state.startTime = now;
1101
+ state.glitchFrame = 0;
1102
+ state.lastGlitchTime = now;
1103
+ }
997
1104
  }
998
1105
  return;
999
1106
  }
1000
- // act: and aim: — existing immediate update with config
1107
+ // act: and aim: — glitch animation
1001
1108
  if (state.lastText === newText) {
1002
1109
  return;
1003
1110
  }
1004
- const hadRipples = state.ripples.length > 0;
1005
- const hadActiveRipplesBefore = state.ripples.some(r => now - r.time < r.dur);
1006
- state.ripples = state.ripples.filter(r => now - r.time < r.dur + (r.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS));
1007
- const justExpired = hadRipples && !hadActiveRipplesBefore;
1008
- const hasActiveRipples = state.ripples.some(r => now - r.time < r.dur);
1009
- if (hasActiveRipples) {
1111
+ // Clear completed glitch queue so we can start a new one
1112
+ if (state.glitchQueue.length > 0) {
1113
+ const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
1114
+ if (isGlitchComplete(state.glitchQueue, frame)) {
1115
+ state.glitchQueue = [];
1116
+ state.glitchFrame = 0;
1117
+ }
1118
+ }
1119
+ if (state.glitchQueue.length > 0) {
1010
1120
  state.lastText = newText;
1011
1121
  return;
1012
1122
  }
1123
+ const hadRipples = state.ripples.length > 0;
1124
+ state.ripples = state.ripples.filter(r => now - r.time < r.dur + (r.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS));
1013
1125
  const cooledDown = now - state.lastAnimTime >= MIN_RIPPLE_INTERVAL;
1014
- if (!cooledDown && !justExpired) {
1126
+ if (!cooledDown && !hadRipples) {
1015
1127
  state.lastText = newText;
1016
1128
  return;
1017
1129
  }
1130
+ const oldDisplayed = state.displayedText;
1018
1131
  state.displayedText = newText;
1019
1132
  state.lastText = newText;
1020
1133
  state.lastFlushTime = now;
1021
1134
  state.lastAnimTime = now;
1022
- const config = lineKey === 'act' ? ILLUMINATE_CONFIGS.actLabel : undefined;
1023
- if (config) {
1024
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(newText.length), now, config, newText.length, undefined, false));
1025
- }
1026
- else {
1027
- state.ripples.push(...spawnRippleForText(randomizedCenter(newText.length), now, newText.length, undefined, false));
1028
- }
1029
- let keep = 0;
1030
- for (let i = 0; i < state.ripples.length; i++) {
1031
- if (now - state.ripples[i].time < state.ripples[i].dur + (state.ripples[i].contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS)) {
1032
- state.ripples[keep++] = state.ripples[i];
1033
- }
1034
- }
1035
- state.ripples.length = keep;
1135
+ state.glitchQueue = buildGlitchQueue(oldDisplayed || '', newText);
1136
+ state.startTime = now;
1137
+ state.glitchFrame = 0;
1138
+ state.lastGlitchTime = now;
1139
+ state.ripples = [];
1036
1140
  return;
1037
1141
  }
1038
1142
  // Standard modes (stream/cascade/ripple)
@@ -1110,10 +1214,14 @@ function createLineState() {
1110
1214
  lastAccessTime: Date.now(),
1111
1215
  lastTextChangeTime: 0,
1112
1216
  lastRippleEndTime: 0,
1217
+ charsSinceLastFlush: 0,
1218
+ glitchQueue: [],
1219
+ glitchFrame: 0,
1220
+ lastGlitchTime: 0,
1113
1221
  };
1114
1222
  }
1115
1223
  function createValueFlashState() {
1116
- return { prev: '', ripples: [], queue: [], queueMaxEnd: 0, startTime: 0, lastValueChangeTime: 0, lastFlashTime: 0, completed: false, lastRippleEndTime: 0 };
1224
+ return { prev: '', ripples: [], queue: [], queueMaxEnd: 0, startTime: 0, lastValueChangeTime: 0, lastFlashTime: 0, completed: false, lastRippleEndTime: 0, glitchQueue: [], glitchFrame: 0, lastGlitchTime: 0 };
1117
1225
  }
1118
1226
  function createTypewriterState(speed) {
1119
1227
  return {
@@ -1262,11 +1370,16 @@ export class ScrambleStateManager {
1262
1370
  state.pendingText = '';
1263
1371
  state.lastFlushTime = 0;
1264
1372
  state.lastRippleEndTime = 0;
1373
+ state.charsSinceLastFlush = 0;
1374
+ state.glitchQueue = [];
1375
+ state.glitchFrame = 0;
1265
1376
  }
1266
1377
  if (isComplete) {
1267
1378
  state.completed = true;
1268
1379
  state.queue = [];
1269
1380
  state.ripples = [];
1381
+ state.glitchQueue = [];
1382
+ state.glitchFrame = 0;
1270
1383
  }
1271
1384
  if (state.completed)
1272
1385
  return { label: key, content: text, isAnimating: false };
@@ -1281,8 +1394,10 @@ export class ScrambleStateManager {
1281
1394
  state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1282
1395
  }
1283
1396
  else if (this.mode === 'illuminate') {
1284
- const updateConfig = key === 'result' ? ILLUMINATE_CONFIGS.msgContent : ILLUMINATE_CONFIGS.flowMeta;
1285
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(text.length), now, updateConfig, text.length, undefined, true));
1397
+ state.glitchQueue = buildGlitchQueue('', text);
1398
+ state.startTime = now;
1399
+ state.lastGlitchTime = now;
1400
+ state.glitchFrame = 0;
1286
1401
  }
1287
1402
  else {
1288
1403
  state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, true));
@@ -1309,8 +1424,10 @@ export class ScrambleStateManager {
1309
1424
  }
1310
1425
  else if (this.mode === 'illuminate') {
1311
1426
  state.ripples = [];
1312
- const updateConfig = key === 'result' ? ILLUMINATE_CONFIGS.msgContent : ILLUMINATE_CONFIGS.flowMeta;
1313
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(text.length), now, updateConfig, text.length, undefined, true));
1427
+ state.glitchQueue = buildGlitchQueue(state.displayedText || '', text);
1428
+ state.startTime = now;
1429
+ state.lastGlitchTime = now;
1430
+ state.glitchFrame = 0;
1314
1431
  }
1315
1432
  else {
1316
1433
  state.ripples = [];
@@ -1348,11 +1465,16 @@ export class ScrambleStateManager {
1348
1465
  state.pendingText = '';
1349
1466
  state.lastFlushTime = 0;
1350
1467
  state.lastRippleEndTime = 0;
1468
+ state.charsSinceLastFlush = 0;
1469
+ state.glitchQueue = [];
1470
+ state.glitchFrame = 0;
1351
1471
  }
1352
1472
  if (isComplete) {
1353
1473
  state.completed = true;
1354
1474
  state.queue = [];
1355
1475
  state.ripples = [];
1476
+ state.glitchQueue = [];
1477
+ state.glitchFrame = 0;
1356
1478
  }
1357
1479
  if (state.completed)
1358
1480
  return { label: 'aim:', content: text, isAnimating: false };
@@ -1371,7 +1493,10 @@ export class ScrambleStateManager {
1371
1493
  state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1372
1494
  }
1373
1495
  else if (this.mode === 'illuminate') {
1374
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(text.length), now, ILLUMINATE_CONFIGS.aimLabel, text.length, undefined, false));
1496
+ state.glitchQueue = buildGlitchQueue('', text);
1497
+ state.startTime = now;
1498
+ state.lastGlitchTime = now;
1499
+ state.glitchFrame = 0;
1375
1500
  }
1376
1501
  else {
1377
1502
  state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, false));
@@ -1398,7 +1523,10 @@ export class ScrambleStateManager {
1398
1523
  }
1399
1524
  else if (this.mode === 'illuminate') {
1400
1525
  state.ripples = [];
1401
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(text.length), now, ILLUMINATE_CONFIGS.aimLabel, text.length, undefined, false));
1526
+ state.glitchQueue = buildGlitchQueue(state.displayedText || '', text);
1527
+ state.startTime = now;
1528
+ state.lastGlitchTime = now;
1529
+ state.glitchFrame = 0;
1402
1530
  }
1403
1531
  else {
1404
1532
  state.ripples = [];
@@ -1409,6 +1537,8 @@ export class ScrambleStateManager {
1409
1537
  else if (!this.isLineAnimating(state, now)) {
1410
1538
  state.queue = [];
1411
1539
  state.ripples = [];
1540
+ state.glitchQueue = [];
1541
+ state.glitchFrame = 0;
1412
1542
  }
1413
1543
  }
1414
1544
  else {
@@ -1440,11 +1570,16 @@ export class ScrambleStateManager {
1440
1570
  state.pendingText = '';
1441
1571
  state.lastFlushTime = 0;
1442
1572
  state.lastRippleEndTime = 0;
1573
+ state.charsSinceLastFlush = 0;
1574
+ state.glitchQueue = [];
1575
+ state.glitchFrame = 0;
1443
1576
  }
1444
1577
  if (isComplete) {
1445
1578
  state.completed = true;
1446
1579
  state.queue = [];
1447
1580
  state.ripples = [];
1581
+ state.glitchQueue = [];
1582
+ state.glitchFrame = 0;
1448
1583
  }
1449
1584
  if (state.completed)
1450
1585
  return { label: 'act:', content: text, isAnimating: false };
@@ -1458,7 +1593,10 @@ export class ScrambleStateManager {
1458
1593
  state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1459
1594
  }
1460
1595
  else if (this.mode === 'illuminate') {
1461
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(text.length), now, ILLUMINATE_CONFIGS.actLabel, text.length, undefined, false));
1596
+ state.glitchQueue = buildGlitchQueue('', text);
1597
+ state.startTime = now;
1598
+ state.lastGlitchTime = now;
1599
+ state.glitchFrame = 0;
1462
1600
  state.displayedText = text;
1463
1601
  }
1464
1602
  else {
@@ -1486,7 +1624,10 @@ export class ScrambleStateManager {
1486
1624
  }
1487
1625
  else if (this.mode === 'illuminate') {
1488
1626
  state.ripples = [];
1489
- state.ripples.push(...spawnIlluminateRippleForText(randomizedCenter(text.length), now, ILLUMINATE_CONFIGS.actLabel, text.length, undefined, false));
1627
+ state.glitchQueue = buildGlitchQueue(state.displayedText || '', text);
1628
+ state.startTime = now;
1629
+ state.lastGlitchTime = now;
1630
+ state.glitchFrame = 0;
1490
1631
  }
1491
1632
  else {
1492
1633
  state.ripples = [];
@@ -1497,6 +1638,8 @@ export class ScrambleStateManager {
1497
1638
  else if (!this.isLineAnimating(state, now)) {
1498
1639
  state.queue = [];
1499
1640
  state.ripples = [];
1641
+ state.glitchQueue = [];
1642
+ state.glitchFrame = 0;
1500
1643
  }
1501
1644
  }
1502
1645
  else {
@@ -1529,11 +1672,15 @@ export class ScrambleStateManager {
1529
1672
  state.pendingText = '';
1530
1673
  state.lastFlushTime = 0;
1531
1674
  state.lastRippleEndTime = 0;
1675
+ state.glitchQueue = [];
1676
+ state.glitchFrame = 0;
1532
1677
  }
1533
1678
  if (isComplete) {
1534
1679
  state.completed = true;
1535
1680
  state.queue = [];
1536
1681
  state.ripples = [];
1682
+ state.glitchQueue = [];
1683
+ state.glitchFrame = 0;
1537
1684
  }
1538
1685
  if (state.completed)
1539
1686
  return { label: 'msg:', content: visibleText, isAnimating: false };
@@ -1576,33 +1723,75 @@ export class ScrambleStateManager {
1576
1723
  state.queue = [];
1577
1724
  const hasActiveRipples = state.ripples.some(r => now - r.time < r.dur);
1578
1725
  const gap = now - state.lastTextChangeTime;
1726
+ const glitchCooledDown = now - state.lastGlitchTime >= GLITCH_COOLDOWN_MS;
1727
+ const previousText = state.lastText;
1579
1728
  if (textChanged) {
1729
+ const delta = Math.max(0, visibleText.length - state.lastText.length);
1580
1730
  state.lastText = visibleText;
1581
1731
  state.phraseBuffer = visibleText;
1582
1732
  state.lastTextChangeTime = now;
1733
+ state.charsSinceLastFlush += delta;
1583
1734
  }
1584
- // If no active ripple and accumulated content meets chunk threshold → fire ripple
1585
- if (!hasActiveRipples && shouldFlushPhrase(visibleText, state.displayedText, state.lastFlushTime, now)) {
1735
+ // F1: accumulator periodic ripples during dense streaming
1736
+ if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && state.charsSinceLastFlush >= 20 && visibleText !== state.displayedText) {
1737
+ const oldDisplayed = previousText || state.displayedText;
1586
1738
  state.displayedText = visibleText;
1587
1739
  state.lastFlushTime = now;
1588
1740
  state.lastAnimTime = now;
1589
- state.ripples.push(...spawnIlluminateRippleForText(randomSentenceStart(visibleText), now, ILLUMINATE_CONFIGS.msgContent, visibleText.length, undefined, true));
1741
+ state.charsSinceLastFlush = 0;
1742
+ state.ripples = [];
1743
+ if (glitchCooledDown) {
1744
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, visibleText);
1745
+ state.startTime = now;
1746
+ state.glitchFrame = 0;
1747
+ state.lastGlitchTime = now;
1748
+ }
1590
1749
  }
1591
- else if (!hasActiveRipples && visibleText !== state.displayedText && now - state.lastTextChangeTime > MSG_CHUNK_DRAIN_MS) {
1750
+ else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && shouldFlushPhrase(visibleText, state.displayedText, state.lastFlushTime, now)) {
1751
+ const oldDisplayed = previousText || state.displayedText;
1752
+ state.displayedText = visibleText;
1753
+ state.lastFlushTime = now;
1754
+ state.lastAnimTime = now;
1755
+ state.charsSinceLastFlush = 0;
1756
+ state.ripples = [];
1757
+ if (glitchCooledDown) {
1758
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, visibleText);
1759
+ state.startTime = now;
1760
+ state.glitchFrame = 0;
1761
+ state.lastGlitchTime = now;
1762
+ }
1763
+ }
1764
+ else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && visibleText !== state.displayedText && now - state.lastTextChangeTime > MSG_CHUNK_DRAIN_MS) {
1592
1765
  // Drain: text stopped arriving and we have unrippled content —
1593
- // ripple it out so it doesn't sit plain indefinitely.
1766
+ // glitch it out so it doesn't sit plain indefinitely.
1767
+ const oldDisplayed = previousText || state.displayedText;
1594
1768
  state.displayedText = visibleText;
1595
1769
  state.lastFlushTime = now;
1596
1770
  state.lastAnimTime = now;
1597
- state.ripples.push(...spawnIlluminateRippleForText(randomSentenceStart(visibleText), now, ILLUMINATE_CONFIGS.msgContent, visibleText.length, undefined, true));
1771
+ state.charsSinceLastFlush = 0;
1772
+ state.ripples = [];
1773
+ if (glitchCooledDown) {
1774
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, visibleText);
1775
+ state.startTime = now;
1776
+ state.glitchFrame = 0;
1777
+ state.lastGlitchTime = now;
1778
+ }
1598
1779
  }
1599
- else if (!hasActiveRipples && visibleText !== state.displayedText && gap > STREAMING_RESUME_GAP_MS) {
1780
+ else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && visibleText !== state.displayedText && gap > STREAMING_RESUME_GAP_MS) {
1600
1781
  // Streaming resumed after a long pause (e.g., tool call) —
1601
- // force a fresh ripple on the accumulated content.
1782
+ // force a fresh glitch on the accumulated content.
1783
+ const oldDisplayed = previousText || state.displayedText;
1602
1784
  state.displayedText = visibleText;
1603
1785
  state.lastFlushTime = now;
1604
1786
  state.lastAnimTime = now;
1605
- state.ripples.push(...spawnIlluminateRippleForText(randomSentenceStart(visibleText), now, ILLUMINATE_CONFIGS.msgContent, visibleText.length, undefined, true));
1787
+ state.charsSinceLastFlush = 0;
1788
+ state.ripples = [];
1789
+ if (glitchCooledDown) {
1790
+ state.glitchQueue = buildGlitchQueue(oldDisplayed, visibleText);
1791
+ state.startTime = now;
1792
+ state.glitchFrame = 0;
1793
+ state.lastGlitchTime = now;
1794
+ }
1606
1795
  }
1607
1796
  }
1608
1797
  else {
@@ -1619,6 +1808,8 @@ export class ScrambleStateManager {
1619
1808
  const hadActiveRipplesBefore = state.ripples.some(r => now - r.time < r.dur);
1620
1809
  state.ripples = state.ripples.filter(r => now - r.time < r.dur + (r.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS));
1621
1810
  state.queue = [];
1811
+ state.glitchQueue = [];
1812
+ state.glitchFrame = 0;
1622
1813
  const justExpired = hadRipples && !hadActiveRipplesBefore;
1623
1814
  if (!textChanged) {
1624
1815
  if (state.displayedText !== visibleText) {
@@ -1839,12 +2030,13 @@ export class ScrambleStateManager {
1839
2030
  state.startTime = now;
1840
2031
  state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1841
2032
  }
1842
- else if (this.mode === 'illuminate') {
1843
- state.ripples = spawnTpsIlluminateRipples(randomizedCenter(value.length), now);
1844
- state.startTime = now;
1845
- }
1846
2033
  else {
1847
- state.ripples = spawnTpsRipples(randomizedCenter(value.length), now);
2034
+ state.glitchQueue = buildGlitchQueue(state.prev, value, GLITCH_SHORT_MAX_START, GLITCH_SHORT_MAX_LENGTH);
2035
+ state.startTime = now;
2036
+ state.lastGlitchTime = now;
2037
+ state.glitchFrame = 0;
2038
+ state.ripples = [];
2039
+ state.queue = [];
1848
2040
  }
1849
2041
  }
1850
2042
  _renderValueFlash(state, value, now) {
@@ -1860,20 +2052,17 @@ export class ScrambleStateManager {
1860
2052
  }
1861
2053
  return value;
1862
2054
  }
1863
- else if (this.mode === 'illuminate') {
1864
- if (state.ripples.some(r => now - r.time < r.dur + FLASH_AFTERGLOW_MS)) {
1865
- return applyRipples(value, state.ripples, now, ILLUMINATE_CONFIGS.tps);
1866
- }
1867
- state.ripples = [];
1868
- state.startTime = now;
1869
- return value;
1870
- }
1871
2055
  else {
1872
- if (state.ripples.some(r => now - r.time < r.dur + FLASH_AFTERGLOW_MS)) {
1873
- return applyRipples(value, state.ripples, now);
2056
+ if (state.glitchQueue.length > 0) {
2057
+ const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2058
+ if (isGlitchComplete(state.glitchQueue, frame)) {
2059
+ state.glitchQueue = [];
2060
+ state.prev = value;
2061
+ return value;
2062
+ }
2063
+ return computeGlitchFrame(state.glitchQueue, frame, () => this.poolRandomChar());
1874
2064
  }
1875
- state.ripples = [];
1876
- state.startTime = now;
2065
+ state.prev = value;
1877
2066
  return value;
1878
2067
  }
1879
2068
  }
@@ -1908,6 +2097,8 @@ export class ScrambleStateManager {
1908
2097
  state.startTime = 0;
1909
2098
  state.lastRippleEndTime = 0;
1910
2099
  state.lastFlashTime = 0;
2100
+ state.glitchQueue = [];
2101
+ state.glitchFrame = 0;
1911
2102
  }
1912
2103
  if (state.completed)
1913
2104
  return state;
@@ -1922,6 +2113,9 @@ export class ScrambleStateManager {
1922
2113
  else if (this.mode === 'cascade') {
1923
2114
  state.queue = [];
1924
2115
  }
2116
+ else {
2117
+ state.glitchQueue = [];
2118
+ }
1925
2119
  state.prev = value;
1926
2120
  }
1927
2121
  if (isFirstCall && staticLine && state.startTime === 0 && cooldownElapsed) {
@@ -2016,6 +2210,10 @@ export class ScrambleStateManager {
2016
2210
  return !isCascadeComplete(state.queue, frame, state.queueMaxEnd);
2017
2211
  }
2018
2212
  else {
2213
+ if (state.glitchQueue.length > 0) {
2214
+ const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2215
+ return !isGlitchComplete(state.glitchQueue, frame);
2216
+ }
2019
2217
  return state.ripples.some((rp) => rp.time + rp.dur + (rp.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS) > now);
2020
2218
  }
2021
2219
  }
@@ -2082,8 +2280,11 @@ export class ScrambleStateManager {
2082
2280
  }
2083
2281
  }
2084
2282
  else {
2085
- if (state.ripples.some(r => r.time + r.dur + FLASH_AFTERGLOW_MS > now))
2086
- return true;
2283
+ if (state.glitchQueue.length > 0) {
2284
+ const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2285
+ if (!isGlitchComplete(state.glitchQueue, frame))
2286
+ return true;
2287
+ }
2087
2288
  }
2088
2289
  }
2089
2290
  for (const state of this.actKpiState.values()) {
@@ -2097,8 +2298,11 @@ export class ScrambleStateManager {
2097
2298
  }
2098
2299
  }
2099
2300
  else {
2100
- if (state.ripples.some(r => r.time + r.dur + FLASH_AFTERGLOW_MS > now))
2101
- return true;
2301
+ if (state.glitchQueue.length > 0) {
2302
+ const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2303
+ if (!isGlitchComplete(state.glitchQueue, frame))
2304
+ return true;
2305
+ }
2102
2306
  }
2103
2307
  }
2104
2308
  for (const state of this.msgKpiState.values()) {
@@ -2112,8 +2316,11 @@ export class ScrambleStateManager {
2112
2316
  }
2113
2317
  }
2114
2318
  else {
2115
- if (state.ripples.some(r => r.time + r.dur + FLASH_AFTERGLOW_MS > now))
2116
- return true;
2319
+ if (state.glitchQueue.length > 0) {
2320
+ const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2321
+ if (!isGlitchComplete(state.glitchQueue, frame))
2322
+ return true;
2323
+ }
2117
2324
  }
2118
2325
  }
2119
2326
  for (const state of this.genericCache.values()) {
@@ -2184,6 +2391,8 @@ export class ScrambleStateManager {
2184
2391
  record[key].pendingText = '';
2185
2392
  record[key].lastFlushTime = 0;
2186
2393
  record[key].lastRippleEndTime = 0;
2394
+ record[key].glitchQueue = [];
2395
+ record[key].glitchFrame = 0;
2187
2396
  }
2188
2397
  }
2189
2398
  const tpsState = this.tpsState.get(id);
@@ -2192,18 +2401,24 @@ export class ScrambleStateManager {
2192
2401
  tpsState.queue = [];
2193
2402
  tpsState.ripples = [];
2194
2403
  tpsState.lastRippleEndTime = 0;
2404
+ tpsState.glitchQueue = [];
2405
+ tpsState.glitchFrame = 0;
2195
2406
  }
2196
2407
  const actKpiState = this.actKpiState.get(id);
2197
2408
  if (actKpiState) {
2198
2409
  actKpiState.completed = true;
2199
2410
  actKpiState.queue = [];
2200
2411
  actKpiState.ripples = [];
2412
+ actKpiState.glitchQueue = [];
2413
+ actKpiState.glitchFrame = 0;
2201
2414
  }
2202
2415
  const msgKpiState = this.msgKpiState.get(id);
2203
2416
  if (msgKpiState) {
2204
2417
  msgKpiState.completed = true;
2205
2418
  msgKpiState.queue = [];
2206
2419
  msgKpiState.ripples = [];
2420
+ msgKpiState.glitchQueue = [];
2421
+ msgKpiState.glitchFrame = 0;
2207
2422
  }
2208
2423
  const streamRecord = this.streamState.get(id);
2209
2424
  if (streamRecord) {
@@ -2219,6 +2434,8 @@ export class ScrambleStateManager {
2219
2434
  state.completed = true;
2220
2435
  state.queue = [];
2221
2436
  state.ripples = [];
2437
+ state.glitchQueue = [];
2438
+ state.glitchFrame = 0;
2222
2439
  state.lastRippleEndTime = 0;
2223
2440
  }
2224
2441
  }