pi-agent-flow 1.8.40 → 2.0.0

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.
Files changed (291) hide show
  1. package/README.md +33 -37
  2. package/agents/audit.md +18 -22
  3. package/agents/build.md +20 -22
  4. package/agents/craft.md +20 -27
  5. package/agents/debug.md +21 -28
  6. package/agents/ideas.md +18 -101
  7. package/agents/scout.md +15 -19
  8. package/dist/batch/batch-bash.d.ts +2 -2
  9. package/dist/batch/batch-bash.d.ts.map +1 -1
  10. package/dist/batch/batch-bash.js +3 -3
  11. package/dist/batch/batch-bash.js.map +1 -1
  12. package/dist/batch/constants.d.ts +19 -5
  13. package/dist/batch/constants.d.ts.map +1 -1
  14. package/dist/batch/constants.js +4 -3
  15. package/dist/batch/constants.js.map +1 -1
  16. package/dist/batch/execute.d.ts +0 -1
  17. package/dist/batch/execute.d.ts.map +1 -1
  18. package/dist/batch/execute.js +97 -6
  19. package/dist/batch/execute.js.map +1 -1
  20. package/dist/batch/fuzzy-edit.d.ts +0 -6
  21. package/dist/batch/fuzzy-edit.d.ts.map +1 -1
  22. package/dist/batch/fuzzy-edit.js +1 -1
  23. package/dist/batch/fuzzy-edit.js.map +1 -1
  24. package/dist/batch/index.d.ts.map +1 -1
  25. package/dist/batch/index.js +87 -16
  26. package/dist/batch/index.js.map +1 -1
  27. package/dist/batch/render.d.ts +0 -1
  28. package/dist/batch/render.d.ts.map +1 -1
  29. package/dist/batch/render.js +7 -101
  30. package/dist/batch/render.js.map +1 -1
  31. package/dist/batch/summary.d.ts +5 -0
  32. package/dist/batch/summary.d.ts.map +1 -0
  33. package/dist/batch/summary.js +101 -0
  34. package/dist/batch/summary.js.map +1 -0
  35. package/dist/{config.d.ts → config/config.d.ts} +34 -2
  36. package/dist/config/config.d.ts.map +1 -0
  37. package/dist/{config.js → config/config.js} +157 -9
  38. package/dist/config/config.js.map +1 -0
  39. package/dist/config/log.d.ts +27 -0
  40. package/dist/config/log.d.ts.map +1 -0
  41. package/dist/config/log.js +104 -0
  42. package/dist/config/log.js.map +1 -0
  43. package/dist/{settings-resolver.d.ts → config/settings-resolver.d.ts} +9 -2
  44. package/dist/config/settings-resolver.d.ts.map +1 -0
  45. package/dist/config/settings-resolver.js +275 -0
  46. package/dist/config/settings-resolver.js.map +1 -0
  47. package/dist/core/agents.d.ts.map +1 -0
  48. package/dist/{agents.js → core/agents.js} +11 -10
  49. package/dist/core/agents.js.map +1 -0
  50. package/dist/core/delegation.d.ts +24 -0
  51. package/dist/core/delegation.d.ts.map +1 -0
  52. package/dist/core/delegation.js +55 -0
  53. package/dist/core/delegation.js.map +1 -0
  54. package/dist/core/depth.d.ts.map +1 -0
  55. package/dist/{depth.js → core/depth.js} +9 -8
  56. package/dist/core/depth.js.map +1 -0
  57. package/dist/{executor.d.ts → core/executor.d.ts} +11 -3
  58. package/dist/core/executor.d.ts.map +1 -0
  59. package/dist/{executor.js → core/executor.js} +49 -14
  60. package/dist/core/executor.js.map +1 -0
  61. package/dist/{flow.d.ts → core/flow.d.ts} +4 -1
  62. package/dist/core/flow.d.ts.map +1 -0
  63. package/dist/{flow.js → core/flow.js} +110 -45
  64. package/dist/core/flow.js.map +1 -0
  65. package/dist/{session-mode.d.ts → core/session-mode.d.ts} +2 -1
  66. package/dist/core/session-mode.d.ts.map +1 -0
  67. package/dist/{session-mode.js → core/session-mode.js} +1 -1
  68. package/dist/core/session-mode.js.map +1 -0
  69. package/dist/core/session-registry.d.ts +16 -0
  70. package/dist/core/session-registry.d.ts.map +1 -0
  71. package/dist/core/session-registry.js +30 -0
  72. package/dist/core/session-registry.js.map +1 -0
  73. package/dist/core/transitions.d.ts.map +1 -0
  74. package/dist/{transitions.js → core/transitions.js} +1 -1
  75. package/dist/core/transitions.js.map +1 -0
  76. package/dist/flow/command.d.ts +8 -0
  77. package/dist/flow/command.d.ts.map +1 -0
  78. package/dist/flow/command.js +189 -0
  79. package/dist/flow/command.js.map +1 -0
  80. package/dist/flow/continuation.d.ts +16 -0
  81. package/dist/flow/continuation.d.ts.map +1 -0
  82. package/dist/flow/continuation.js +151 -0
  83. package/dist/flow/continuation.js.map +1 -0
  84. package/dist/flow/index.d.ts +15 -0
  85. package/dist/flow/index.d.ts.map +1 -0
  86. package/dist/flow/index.js +22 -0
  87. package/dist/flow/index.js.map +1 -0
  88. package/dist/flow/settings-command.d.ts +51 -0
  89. package/dist/flow/settings-command.d.ts.map +1 -0
  90. package/dist/flow/settings-command.js +851 -0
  91. package/dist/flow/settings-command.js.map +1 -0
  92. package/dist/flow/store.d.ts +26 -0
  93. package/dist/flow/store.d.ts.map +1 -0
  94. package/dist/flow/store.js +158 -0
  95. package/dist/flow/store.js.map +1 -0
  96. package/dist/flow/template-strings.d.ts +8 -0
  97. package/dist/flow/template-strings.d.ts.map +1 -0
  98. package/dist/flow/template-strings.js +39 -0
  99. package/dist/flow/template-strings.js.map +1 -0
  100. package/dist/flow/types.d.ts +55 -0
  101. package/dist/flow/types.d.ts.map +1 -0
  102. package/dist/flow/types.js +5 -0
  103. package/dist/flow/types.js.map +1 -0
  104. package/dist/flow/warp-command.d.ts +9 -0
  105. package/dist/flow/warp-command.d.ts.map +1 -0
  106. package/dist/flow/warp-command.js +405 -0
  107. package/dist/flow/warp-command.js.map +1 -0
  108. package/dist/index.d.ts +3 -1
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +103 -29
  111. package/dist/index.js.map +1 -1
  112. package/dist/{notify-state.d.ts → notify/notify-state.d.ts} +2 -1
  113. package/dist/notify/notify-state.d.ts.map +1 -0
  114. package/dist/notify/notify-state.js.map +1 -0
  115. package/dist/notify/notify.d.ts.map +1 -0
  116. package/dist/{notify.js → notify/notify.js} +3 -2
  117. package/dist/notify/notify.js.map +1 -0
  118. package/dist/{cli-args.d.ts → snapshot/cli-args.d.ts} +3 -2
  119. package/dist/snapshot/cli-args.d.ts.map +1 -0
  120. package/dist/{cli-args.js → snapshot/cli-args.js} +1 -1
  121. package/dist/snapshot/cli-args.js.map +1 -0
  122. package/dist/snapshot/index.d.ts +2 -0
  123. package/dist/snapshot/index.d.ts.map +1 -0
  124. package/dist/snapshot/index.js +2 -0
  125. package/dist/snapshot/index.js.map +1 -0
  126. package/dist/{reasoning-strip.d.ts → snapshot/reasoning-strip.d.ts} +0 -4
  127. package/dist/snapshot/reasoning-strip.d.ts.map +1 -0
  128. package/dist/{reasoning-strip.js → snapshot/reasoning-strip.js} +2 -2
  129. package/dist/snapshot/reasoning-strip.js.map +1 -0
  130. package/dist/snapshot/runner-events.d.ts.map +1 -0
  131. package/dist/{runner-events.js → snapshot/runner-events.js} +1 -1
  132. package/dist/snapshot/runner-events.js.map +1 -0
  133. package/dist/{snapshot.d.ts → snapshot/snapshot.d.ts} +5 -2
  134. package/dist/snapshot/snapshot.d.ts.map +1 -0
  135. package/dist/{snapshot.js → snapshot/snapshot.js} +166 -35
  136. package/dist/snapshot/snapshot.js.map +1 -0
  137. package/dist/{structured-output.d.ts → snapshot/structured-output.d.ts} +1 -1
  138. package/dist/snapshot/structured-output.d.ts.map +1 -0
  139. package/dist/snapshot/structured-output.js.map +1 -0
  140. package/dist/{flow-prompt.d.ts → steering/flow-prompt.d.ts} +2 -2
  141. package/dist/steering/flow-prompt.d.ts.map +1 -0
  142. package/dist/{flow-prompt.js → steering/flow-prompt.js} +1 -1
  143. package/dist/steering/flow-prompt.js.map +1 -0
  144. package/dist/{sliding-prompt.d.ts → steering/sliding-prompt.d.ts} +8 -7
  145. package/dist/steering/sliding-prompt.d.ts.map +1 -0
  146. package/dist/{sliding-prompt.js → steering/sliding-prompt.js} +15 -64
  147. package/dist/steering/sliding-prompt.js.map +1 -0
  148. package/dist/{tool-utils.d.ts → steering/tool-utils.d.ts} +1 -0
  149. package/dist/steering/tool-utils.d.ts.map +1 -0
  150. package/dist/{tool-utils.js → steering/tool-utils.js} +10 -3
  151. package/dist/steering/tool-utils.js.map +1 -0
  152. package/dist/{ask-user.d.ts → tools/ask-user.d.ts} +3 -15
  153. package/dist/tools/ask-user.d.ts.map +1 -0
  154. package/dist/tools/ask-user.js +778 -0
  155. package/dist/tools/ask-user.js.map +1 -0
  156. package/dist/{timed-bash.d.ts → tools/timed-bash.d.ts} +2 -7
  157. package/dist/tools/timed-bash.d.ts.map +1 -0
  158. package/dist/{timed-bash.js → tools/timed-bash.js} +2 -2
  159. package/dist/tools/timed-bash.js.map +1 -0
  160. package/dist/{web-tool.d.ts → tools/web-tool.d.ts} +1 -1
  161. package/dist/tools/web-tool.d.ts.map +1 -0
  162. package/dist/{web-tool.js → tools/web-tool.js} +8 -7
  163. package/dist/tools/web-tool.js.map +1 -0
  164. package/dist/tui/flow-colors.d.ts +55 -0
  165. package/dist/tui/flow-colors.d.ts.map +1 -0
  166. package/dist/tui/flow-colors.js +22 -0
  167. package/dist/tui/flow-colors.js.map +1 -0
  168. package/dist/{render-utils.d.ts → tui/render-utils.d.ts} +1 -1
  169. package/dist/tui/render-utils.d.ts.map +1 -0
  170. package/dist/{render-utils.js → tui/render-utils.js} +3 -3
  171. package/dist/tui/render-utils.js.map +1 -0
  172. package/dist/tui/render.d.ts +21 -0
  173. package/dist/tui/render.d.ts.map +1 -0
  174. package/dist/tui/render.js +813 -0
  175. package/dist/tui/render.js.map +1 -0
  176. package/dist/tui/scramble/algorithm.d.ts +7 -0
  177. package/dist/tui/scramble/algorithm.d.ts.map +1 -0
  178. package/dist/tui/scramble/algorithm.js +227 -0
  179. package/dist/tui/scramble/algorithm.js.map +1 -0
  180. package/dist/tui/scramble/constants.d.ts +99 -0
  181. package/dist/tui/scramble/constants.d.ts.map +1 -0
  182. package/dist/tui/scramble/constants.js +101 -0
  183. package/dist/tui/scramble/constants.js.map +1 -0
  184. package/dist/tui/scramble/index.d.ts +6 -0
  185. package/dist/tui/scramble/index.d.ts.map +1 -0
  186. package/dist/tui/scramble/index.js +6 -0
  187. package/dist/tui/scramble/index.js.map +1 -0
  188. package/dist/tui/scramble/manager.d.ts +48 -0
  189. package/dist/tui/scramble/manager.d.ts.map +1 -0
  190. package/dist/tui/scramble/manager.js +959 -0
  191. package/dist/tui/scramble/manager.js.map +1 -0
  192. package/dist/tui/scramble/utils.d.ts +18 -0
  193. package/dist/tui/scramble/utils.d.ts.map +1 -0
  194. package/dist/tui/scramble/utils.js +145 -0
  195. package/dist/tui/scramble/utils.js.map +1 -0
  196. package/dist/tui/single-select-layout.d.ts +17 -0
  197. package/dist/tui/single-select-layout.d.ts.map +1 -0
  198. package/dist/{single-select-layout.js → tui/single-select-layout.js} +8 -25
  199. package/dist/tui/single-select-layout.js.map +1 -0
  200. package/dist/types/flow.d.ts +110 -0
  201. package/dist/types/flow.d.ts.map +1 -0
  202. package/dist/{types.js → types/flow.js} +3 -54
  203. package/dist/types/flow.js.map +1 -0
  204. package/dist/types/index.d.ts +8 -0
  205. package/dist/types/index.d.ts.map +1 -0
  206. package/dist/types/index.js +7 -0
  207. package/dist/types/index.js.map +1 -0
  208. package/dist/types/output.d.ts +104 -0
  209. package/dist/types/output.d.ts.map +1 -0
  210. package/dist/types/output.js +5 -0
  211. package/dist/types/output.js.map +1 -0
  212. package/dist/types/ui.d.ts +24 -0
  213. package/dist/types/ui.d.ts.map +1 -0
  214. package/dist/types/ui.js +55 -0
  215. package/dist/types/ui.js.map +1 -0
  216. package/package.json +1 -1
  217. package/dist/agents.d.ts.map +0 -1
  218. package/dist/agents.js.map +0 -1
  219. package/dist/ask-user.d.ts.map +0 -1
  220. package/dist/ask-user.js +0 -1405
  221. package/dist/ask-user.js.map +0 -1
  222. package/dist/batch.d.ts +0 -12
  223. package/dist/batch.d.ts.map +0 -1
  224. package/dist/batch.js +0 -11
  225. package/dist/batch.js.map +0 -1
  226. package/dist/cli-args.d.ts.map +0 -1
  227. package/dist/cli-args.js.map +0 -1
  228. package/dist/config.d.ts.map +0 -1
  229. package/dist/config.js.map +0 -1
  230. package/dist/depth.d.ts.map +0 -1
  231. package/dist/depth.js.map +0 -1
  232. package/dist/executor.d.ts.map +0 -1
  233. package/dist/executor.js.map +0 -1
  234. package/dist/flow-prompt.d.ts.map +0 -1
  235. package/dist/flow-prompt.js.map +0 -1
  236. package/dist/flow.d.ts.map +0 -1
  237. package/dist/flow.js.map +0 -1
  238. package/dist/notify-state.d.ts.map +0 -1
  239. package/dist/notify-state.js.map +0 -1
  240. package/dist/notify.d.ts.map +0 -1
  241. package/dist/notify.js.map +0 -1
  242. package/dist/reasoning-strip.d.ts.map +0 -1
  243. package/dist/reasoning-strip.js.map +0 -1
  244. package/dist/render-utils.d.ts.map +0 -1
  245. package/dist/render-utils.js.map +0 -1
  246. package/dist/render.d.ts +0 -24
  247. package/dist/render.d.ts.map +0 -1
  248. package/dist/render.js +0 -592
  249. package/dist/render.js.map +0 -1
  250. package/dist/runner-events.d.ts.map +0 -1
  251. package/dist/runner-events.js.map +0 -1
  252. package/dist/scramble.d.ts +0 -183
  253. package/dist/scramble.d.ts.map +0 -1
  254. package/dist/scramble.js +0 -2478
  255. package/dist/scramble.js.map +0 -1
  256. package/dist/session-mode.d.ts.map +0 -1
  257. package/dist/session-mode.js.map +0 -1
  258. package/dist/settings-resolver.d.ts.map +0 -1
  259. package/dist/settings-resolver.js +0 -148
  260. package/dist/settings-resolver.js.map +0 -1
  261. package/dist/single-select-layout.d.ts +0 -20
  262. package/dist/single-select-layout.d.ts.map +0 -1
  263. package/dist/single-select-layout.js.map +0 -1
  264. package/dist/sliding-prompt.d.ts.map +0 -1
  265. package/dist/sliding-prompt.js.map +0 -1
  266. package/dist/snapshot.d.ts.map +0 -1
  267. package/dist/snapshot.js.map +0 -1
  268. package/dist/spec-mode.d.ts +0 -13
  269. package/dist/spec-mode.d.ts.map +0 -1
  270. package/dist/spec-mode.js +0 -90
  271. package/dist/spec-mode.js.map +0 -1
  272. package/dist/structured-output.d.ts.map +0 -1
  273. package/dist/structured-output.js.map +0 -1
  274. package/dist/timed-bash.d.ts.map +0 -1
  275. package/dist/timed-bash.js.map +0 -1
  276. package/dist/tool-utils.d.ts.map +0 -1
  277. package/dist/tool-utils.js.map +0 -1
  278. package/dist/transitions.d.ts.map +0 -1
  279. package/dist/transitions.js.map +0 -1
  280. package/dist/types.d.ts +0 -224
  281. package/dist/types.d.ts.map +0 -1
  282. package/dist/types.js.map +0 -1
  283. package/dist/web-tool.d.ts.map +0 -1
  284. package/dist/web-tool.js.map +0 -1
  285. /package/dist/{agents.d.ts → core/agents.d.ts} +0 -0
  286. /package/dist/{depth.d.ts → core/depth.d.ts} +0 -0
  287. /package/dist/{transitions.d.ts → core/transitions.d.ts} +0 -0
  288. /package/dist/{notify-state.js → notify/notify-state.js} +0 -0
  289. /package/dist/{notify.d.ts → notify/notify.d.ts} +0 -0
  290. /package/dist/{runner-events.d.ts → snapshot/runner-events.d.ts} +0 -0
  291. /package/dist/{structured-output.js → snapshot/structured-output.js} +0 -0
package/dist/scramble.js DELETED
@@ -1,2478 +0,0 @@
1
- /**
2
- * Quad-mode text scramble effect for terminal TUI.
3
- *
4
- * Mode 1 — STREAM: Typewriter-style progressive reveal.
5
- * Buffer the full text, reveal character-by-character with a scramble
6
- * cursor at the writing position. Works naturally with streaming text —
7
- * the cursor follows the stream, creating a "typing" effect.
8
- *
9
- * Mode 2 — CASCADE: Classic TextScramble algorithm (Justin Windle).
10
- * Per-character queue with staggered start/end frames. Characters decode
11
- * one-by-one in a left-to-right cascade. Self-terminating after ~640ms.
12
- *
13
- * Mode 3 — RIPPLE: Hermes radial wave propagation.
14
- * Wave expands from a center point. Characters resolve behind the wavefront.
15
- *
16
- * Mode 4 — ILLUMINATE: Neon glow ripple with depth-based esoteric char sets,
17
- * ANSI truecolor, phrase-chunked msg streaming, and TPS hysteresis.
18
- * Per-target color configs (sky aim, warm act, peach TPS, etc.).
19
- *
20
- * Line behavior (all modes):
21
- * aim: — content stays still, no animation ever
22
- * act: — stream/cascade/ripple/illuminate on text change
23
- * msg: — stream/cascade/ripple/illuminate on text change
24
- * tps: — flash on value change (cascade/ripple/illuminate only)
25
- */
26
- import { stripAnsi, tailText } from './render-utils.js';
27
- // ---------------------------------------------------------------------------
28
- // Fast RNG (xorshift32) + hash-based noise
29
- // ---------------------------------------------------------------------------
30
- export class FastRNG {
31
- s;
32
- constructor(seed) { this.s = seed >>> 0; }
33
- next() {
34
- let s = this.s;
35
- s ^= s << 13;
36
- s ^= s >>> 17;
37
- s ^= s << 5;
38
- this.s = s >>> 0;
39
- return (s >>> 0) / 0xFFFFFFFF;
40
- }
41
- nextInt(max) {
42
- return Math.floor(this.next() * max);
43
- }
44
- }
45
- export function makeAnimationSeed(text, timestamp) {
46
- let h = 2166136261;
47
- for (let i = 0; i < text.length; i++) {
48
- h ^= text.charCodeAt(i);
49
- h = Math.imul(h, 16777619);
50
- }
51
- return ((h ^ timestamp) >>> 0);
52
- }
53
- const hashNoiseCache = new Map();
54
- const MAX_HASH_CACHE_SIZE = 4096;
55
- export function hashNoise(seed, charIndex, tick, depth) {
56
- const key = (((seed * 31 + charIndex) * 31 + tick) * 7 + depth) >>> 0;
57
- const cached = hashNoiseCache.get(key);
58
- if (cached !== undefined)
59
- return cached;
60
- let h = Math.imul(seed ^ charIndex, 0x45d9f3b);
61
- h = Math.imul(h ^ tick, 0x45d9f3b);
62
- h = Math.imul(h ^ depth, 0x45d9f3b);
63
- h ^= h >>> 16;
64
- const result = (h >>> 0) / 0xFFFFFFFF;
65
- if (hashNoiseCache.size < MAX_HASH_CACHE_SIZE) {
66
- hashNoiseCache.set(key, result);
67
- }
68
- return result;
69
- }
70
- // ---------------------------------------------------------------------------
71
- // Character sets — depth-based esoteric scramble symbols (illuminate mode)
72
- // ---------------------------------------------------------------------------
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
- }
89
- function selectScrambleChar(depth, dist, elapsed, seed, textLen) {
90
- const tickMs = (textLen !== undefined && textLen < 20) ? 300 : 150;
91
- const tick = Math.floor(elapsed / tickMs);
92
- if (seed !== undefined) {
93
- const n = hashNoise(seed, dist, tick, depth);
94
- let char;
95
- if (depth < 2.5) {
96
- // Blend deep→mid across [1.5, 2.5]
97
- const t = smoothstep(1.5, 2.5, depth);
98
- const deepIdx = Math.floor(n * DEEP_GLITCH.length);
99
- const midIdx = Math.floor(n * MID_GLITCH.length);
100
- char = n < t ? MID_GLITCH[midIdx] : DEEP_GLITCH[deepIdx];
101
- }
102
- else if (depth < 3.5) {
103
- // Blend mid→shallow across [2.5, 3.5]
104
- const t = smoothstep(2.5, 3.5, depth);
105
- const midIdx = Math.floor(n * MID_GLITCH.length);
106
- const shallowIdx = Math.floor(n * SHALLOW_GLITCH.length);
107
- char = n < t ? SHALLOW_GLITCH[shallowIdx] : MID_GLITCH[midIdx];
108
- }
109
- else {
110
- const shallowIdx = Math.floor(n * SHALLOW_GLITCH.length);
111
- char = SHALLOW_GLITCH[shallowIdx];
112
- }
113
- return char;
114
- }
115
- // Deterministic fallback (backward compatible)
116
- const jitter = 0;
117
- if (depth <= 2) {
118
- const idx = (3 * dist + tick + jitter) % DEEP_GLITCH.length;
119
- return DEEP_GLITCH[idx < 0 ? idx + DEEP_GLITCH.length : idx];
120
- }
121
- else if (depth === 3) {
122
- const idx = (5 * dist + tick + jitter) % MID_GLITCH.length;
123
- return MID_GLITCH[idx < 0 ? idx + MID_GLITCH.length : idx];
124
- }
125
- else {
126
- const idx = (7 * dist + tick + jitter) % SHALLOW_GLITCH.length;
127
- return SHALLOW_GLITCH[idx < 0 ? idx + SHALLOW_GLITCH.length : idx];
128
- }
129
- }
130
- function selectSparkChar(seed, charIndex, tick) {
131
- const n = hashNoise(seed, charIndex, tick, 88);
132
- const idx = Math.floor(n * THIN_BRAILLE_SPARK.length);
133
- return THIN_BRAILLE_SPARK[idx < 0 ? idx + THIN_BRAILLE_SPARK.length : idx];
134
- }
135
- // ---------------------------------------------------------------------------
136
- // ANSI truecolor neon glow constants (illuminate mode)
137
- // ---------------------------------------------------------------------------
138
- const CYAN_GLOW = '\x1b[38;2;0;255;204m';
139
- const WARM_GLOW = '\x1b[38;2;255;140;120m';
140
- const PEACH_GLOW = '\x1b[38;2;255;160;140m';
141
- const ORANGE_GLOW = '\x1b[38;2;255;190;130m';
142
- const SKY_GLOW = '\x1b[38;2;80;170;255m';
143
- const WHITE_GLOW = '\x1b[38;2;255;255;255m';
144
- const RESET_COLOR = '\x1b[39m';
145
- const BOLD_ON = '\x1b[1m';
146
- const BOLD_OFF = '\x1b[22m';
147
- const DIM_ON = '\x1b[2m';
148
- const DIM_OFF = '\x1b[22m';
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. */
151
- const ILLUMINATE_CLOSE = '\x1b[39m';
152
- const ILLUMINATE_CONFIGS = {
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 },
159
- };
160
- // ---------------------------------------------------------------------------
161
- // Timing constants
162
- // ---------------------------------------------------------------------------
163
- const RIPPLE_DUR_DEFAULT = 520;
164
- const RIPPLE_SPREAD_DEFAULT = 1;
165
- const MIN_RIPPLE_INTERVAL = 300;
166
- const DEPTH_BAND_MAX = 7;
167
- const TPS_FLASH_DUR = 105;
168
- const TPS_FLASH_SPREAD = 0.5;
169
- const AFTERGLOW_MS = 420;
170
- const ECHO_AFTERGLOW_MS = 650;
171
- const FLASH_AFTERGLOW_MS = 137; // shorter afterglow for TPS/KPI value flashes
172
- const PULSE_WINDOW_MS = 600;
173
- const PULSE_CYCLE_MS = 998;
174
- const CASCADE_FRAME_MS = 11;
175
- const CASCADE_MAX_START = 28;
176
- const CASCADE_MAX_LENGTH = 28;
177
- const CASCADE_FLASH_MAX_START = 4;
178
- const CASCADE_FLASH_MAX_LENGTH = 6;
179
- // Illuminate phrase buffering
180
- const MAX_PHRASE_BUFFER_TIME = 560;
181
- const MIN_PHRASE_LENGTH = 60;
182
- // Drain timeout: partial chunk ripples when text stops changing for this long.
183
- // Tokens arrive ~200ms apart at 196 TPS; 350ms is long enough to avoid firing
184
- // during active streaming but short enough to feel responsive when tool calls pause.
185
- const MSG_CHUNK_DRAIN_MS = 120;
186
- // Resume gap: after a long pause (e.g. tool call), treat resumed chunks as a
187
- // fresh stream and force a ripple effect.
188
- const STREAMING_RESUME_GAP_MS = 2000;
189
- // TPS hysteresis
190
- const SECONDARY_RIPPLE_DELAY_MS = 84;
191
- const SECONDARY_RIPPLE_STRENGTH = 0.75;
192
- // TPS hysteresis
193
- const TPS_HYSTERESIS_PCT = 0.15;
194
- const TPS_HYSTERESIS_MS = 2000;
195
- const TPS_FLASH_COOLDOWN_MS = 3000;
196
- // Stream mode constants
197
- const STREAM_SPEED_MSG = 35; // ms per char for msg: (~29 chars/sec)
198
- const STREAM_SPEED_ACT = 25; // ms per char for act: (~40 chars/sec)
199
- const STREAM_SCRAMBLE_WIDTH = 5; // scramble chars at cursor position
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;
208
- // ---------------------------------------------------------------------------
209
- // Easing and interpolation helpers
210
- // ---------------------------------------------------------------------------
211
- /** Ease-out cubic: organic deceleration for ripple expansion.
212
- * Blended 70% ease-out + 30% linear for a snappier wavefront. */
213
- function easeOutCubic(t) {
214
- const et = 1 - Math.pow(1 - Math.min(1, Math.max(0, t)), 3);
215
- return 0.7 * et + 0.3 * Math.min(1, Math.max(0, t));
216
- }
217
- /** Smoothstep interpolation for smooth color band transitions */
218
- function smoothstep(min, max, value) {
219
- const x = Math.max(0, Math.min(1, (value - min) / (max - min)));
220
- return x * x * (3 - 2 * x);
221
- }
222
- /** Linear interpolation between a and b by factor t (0..1) */
223
- function lerp(a, b, t) {
224
- return Math.round(a + (b - a) * Math.max(0, Math.min(1, t)));
225
- }
226
- /** Ease-in quadratic: gentle start, accelerating into the main wave */
227
- function easeInQuad(t) {
228
- return t * t;
229
- }
230
- /** Ease-out quadratic: fast start, gentle deceleration — used for
231
- * distributing cascade start frames more evenly across the range. */
232
- function easeOutQuad(t) {
233
- return 1 - (1 - t) * (1 - t);
234
- }
235
- export { selectScrambleChar };
236
- export { selectSparkChar };
237
- export { THIN_BRAILLE_SPARK };
238
- export { ILLUMINATE_CONFIGS };
239
- export { CYAN_GLOW, WARM_GLOW, PEACH_GLOW, ORANGE_GLOW, SKY_GLOW, WHITE_GLOW, BOLD_ON, BOLD_OFF, RESET_COLOR };
240
- export const DEFAULT_MODE = 'illuminate';
241
- /** Phrase boundary detection for illuminate msg: streaming */
242
- function findPhraseBoundary(text, minLen = MIN_PHRASE_LENGTH) {
243
- // Sentence boundaries — flush regardless of length
244
- const sentenceBoundaries = ['. ', '! ', '? ', '\n'];
245
- for (const b of sentenceBoundaries) {
246
- const idx = text.lastIndexOf(b);
247
- if (idx >= 0)
248
- return idx + b.length;
249
- }
250
- // Other boundaries require min length
251
- if (text.length < minLen)
252
- return -1;
253
- const otherBoundaries = ['— ', '– '];
254
- for (const b of otherBoundaries) {
255
- const idx = text.lastIndexOf(b);
256
- if (idx >= 0)
257
- return idx + b.length;
258
- }
259
- // Fallback: word boundary (space)
260
- const spaceIdx = text.indexOf(' ', minLen);
261
- if (spaceIdx >= 0)
262
- return spaceIdx + 1;
263
- return -1;
264
- }
265
- function shouldFlushPhrase(text, displayed, lastFlushTime, now) {
266
- if (text === displayed)
267
- return false;
268
- // If text is completely different (not incremental), check if it's just a slide
269
- if (!text.startsWith(displayed) && !displayed.startsWith(text)) {
270
- // Tail-view windows slide: old suffix overlaps new prefix.
271
- // If overlap is significant (>50%), treat as a slide, not a rewrite.
272
- const overlap = computeOverlapLen(displayed, text);
273
- const minLen = Math.min(displayed.length, text.length);
274
- if (overlap > 0 && overlap >= minLen * 0.5) {
275
- return now - lastFlushTime > MAX_PHRASE_BUFFER_TIME;
276
- }
277
- return true;
278
- }
279
- // Check buffer timeout
280
- if (now - lastFlushTime > MAX_PHRASE_BUFFER_TIME)
281
- return true;
282
- // Find new content added since displayed
283
- let newContent = '';
284
- if (text.startsWith(displayed)) {
285
- newContent = text.slice(displayed.length);
286
- }
287
- else {
288
- newContent = text;
289
- }
290
- const boundaryPos = findPhraseBoundary(newContent);
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;
298
- }
299
- // ---------------------------------------------------------------------------
300
- // Pure helpers
301
- // ---------------------------------------------------------------------------
302
- function randomChar() {
303
- return SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
304
- }
305
- // ---------------------------------------------------------------------------
306
- // Fast random char pool — pre-filled to reduce Math.random() calls ~80%
307
- // ---------------------------------------------------------------------------
308
- const RANDOM_POOL_SIZE = 2048;
309
- const POOL_REFILL_THRESHOLD = 512; // refill when 25% remaining
310
- let randomPool = [];
311
- let randomPoolIndex = 0;
312
- function fillRandomPool(rng) {
313
- randomPool = new Array(RANDOM_POOL_SIZE);
314
- for (let i = 0; i < RANDOM_POOL_SIZE; i++) {
315
- if (rng) {
316
- randomPool[i] = SCRAMBLE_CHARS[rng.nextInt(SCRAMBLE_CHARS.length)];
317
- }
318
- else {
319
- randomPool[i] = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
320
- }
321
- }
322
- randomPoolIndex = 0;
323
- }
324
- function poolRandomChar() {
325
- if (randomPoolIndex >= randomPool.length - POOL_REFILL_THRESHOLD) {
326
- fillRandomPool();
327
- }
328
- return randomPool[randomPoolIndex++];
329
- }
330
- // ---------------------------------------------------------------------------
331
- // Pre-allocated segment buffer — reused across frames to reduce GC pressure
332
- // ---------------------------------------------------------------------------
333
- let segmentBuffer = [];
334
- function getSegmentBuffer(minSize) {
335
- if (segmentBuffer.length < minSize) {
336
- segmentBuffer = new Array(Math.max(minSize, 512));
337
- }
338
- return segmentBuffer;
339
- }
340
- // ---------------------------------------------------------------------------
341
- // Pure algorithm: STREAM (typewriter progressive reveal)
342
- // ---------------------------------------------------------------------------
343
- /**
344
- * Render visible text with typewriter stream effect.
345
- *
346
- * - Characters before `visibleRevealed` are shown normally (resolved).
347
- * - Characters in the cursor zone (visibleRevealed to visibleRevealed+scrambleWidth)
348
- * show scramble chars with 28% re-randomize rate (CodePen feel).
349
- * - Characters beyond the cursor show pure noise scramble chars.
350
- * - Spaces are always preserved.
351
- */
352
- export function renderStreamText(visibleText, visibleRevealed, scrambleWidth, cursorChars, rng) {
353
- if (visibleRevealed >= visibleText.length)
354
- return visibleText;
355
- let result = '';
356
- let inDim = false;
357
- for (let i = 0; i < visibleText.length; i++) {
358
- const isResolved = i < visibleRevealed;
359
- const isCursorZone = !isResolved && i < visibleRevealed + scrambleWidth;
360
- const ch = visibleText[i];
361
- if (isResolved || ch === ' ') {
362
- if (inDim) {
363
- result += DIM_OFF;
364
- inDim = false;
365
- }
366
- result += ch;
367
- }
368
- else if (isCursorZone) {
369
- if (!inDim) {
370
- result += DIM_ON;
371
- inDim = true;
372
- }
373
- const cursorIdx = i - visibleRevealed;
374
- const getChar = rng ?? poolRandomChar;
375
- while (cursorChars.length <= cursorIdx)
376
- cursorChars.push(getChar());
377
- if (Math.random() < STREAM_RERANDOMIZE_RATE || !cursorChars[cursorIdx]) {
378
- cursorChars[cursorIdx] = getChar();
379
- }
380
- result += cursorChars[cursorIdx];
381
- }
382
- else {
383
- // Beyond cursor — live scramble (keeps fuzzing each frame)
384
- if (!inDim) {
385
- result += DIM_ON;
386
- inDim = true;
387
- }
388
- result += (rng ?? poolRandomChar)();
389
- }
390
- }
391
- if (inDim) {
392
- result += DIM_OFF;
393
- }
394
- // Trim cursor chars array to actual size used
395
- cursorChars.length = Math.min(scrambleWidth, Math.max(0, visibleText.length - visibleRevealed));
396
- return result;
397
- }
398
- // ---------------------------------------------------------------------------
399
- // Pure algorithm: CASCADE (TextScramble by Justin Windle, terminal port)
400
- // ---------------------------------------------------------------------------
401
- export function buildQueue(oldText, newText, maxStart = CASCADE_MAX_START, maxLength = CASCADE_MAX_LENGTH, rng) {
402
- const queue = [];
403
- const cleanOld = stripDecorativeIcons(oldText);
404
- const cleanNew = stripDecorativeIcons(newText);
405
- const length = Math.max(cleanOld.length, cleanNew.length);
406
- const useRng = rng ?? new FastRNG(makeAnimationSeed(newText, Date.now()));
407
- for (let i = 0; i < length; i++) {
408
- const from = oldText[i] || '';
409
- const to = newText[i] || '';
410
- const t = length <= 1 ? 0 : i / (length - 1);
411
- const baseStart = easeOutQuad(t) * maxStart * 0.55;
412
- const jitter = useRng.next() * maxStart * 0.45;
413
- const start = Math.floor(baseStart + jitter);
414
- // Asymmetric end: late chars resolve more slowly using easeOutCubic
415
- const endEase = easeOutCubic(1 - t);
416
- const end = start + Math.floor((0.5 + 0.5 * endEase) * useRng.next() * maxLength);
417
- queue.push({ from, to, start, end });
418
- }
419
- return queue;
420
- }
421
- export function computeCascadeFrame(queue, frame, rng) {
422
- const clampedFrame = Math.max(0, frame);
423
- let result = '';
424
- let inDim = false;
425
- const getChar = rng ?? poolRandomChar;
426
- for (const item of queue) {
427
- if (item.to === ' ') {
428
- if (inDim) {
429
- result += DIM_OFF;
430
- inDim = false;
431
- }
432
- result += ' ';
433
- continue;
434
- }
435
- if (clampedFrame >= item.end) {
436
- if (inDim) {
437
- result += DIM_OFF;
438
- inDim = false;
439
- }
440
- result += item.to;
441
- }
442
- else if (clampedFrame >= item.start) {
443
- if (!inDim) {
444
- result += DIM_ON;
445
- inDim = true;
446
- }
447
- result += getChar();
448
- }
449
- else {
450
- if (item.from === ' ') {
451
- if (inDim) {
452
- result += DIM_OFF;
453
- inDim = false;
454
- }
455
- result += ' ';
456
- }
457
- else {
458
- if (!inDim) {
459
- result += DIM_ON;
460
- inDim = true;
461
- }
462
- result += getChar();
463
- }
464
- }
465
- }
466
- if (inDim)
467
- result += DIM_OFF;
468
- return result;
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
- }
543
- function isCascadeComplete(queue, frame, maxEnd) {
544
- const clampedFrame = Math.max(0, frame);
545
- if (maxEnd !== undefined)
546
- return clampedFrame >= maxEnd;
547
- for (const item of queue) {
548
- if (clampedFrame < item.end)
549
- return false;
550
- }
551
- return true;
552
- }
553
- // ---------------------------------------------------------------------------
554
- // Pure algorithm: RIPPLE (Hermes radial wave)
555
- // ---------------------------------------------------------------------------
556
- /** Build the ANSI prefix for a scramble char based on illuminate config */
557
- function illuminatePrefix(depth, elapsed, dur, config, combinedDepth) {
558
- if (config.color === 'dynamic') {
559
- const progress = Math.min(1, Math.max(0, elapsed / dur));
560
- // heat = how deep in the ripple (0..1), life = how early in animation (1..0)
561
- const heat = Math.min(1, depth / DEPTH_BAND_MAX);
562
- const life = 1 - progress;
563
- const intensity = heat * life * (1 - 0.25 * heat);
564
- // 5-zone continuous truecolor gradient: deep sky → bright sky → sky-peach bridge → vivid peach → rich salmon → warm white peak
565
- let r, g, b;
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);
589
- }
590
- else {
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);
595
- }
596
- // Interference boost: overlapping ripples warm-white flash
597
- const effectiveCombined = combinedDepth ?? depth;
598
- const interferenceBoost = Math.max(0, (effectiveCombined - DEPTH_BAND_MAX * 0.6) / DEPTH_BAND_MAX);
599
- if (interferenceBoost > 0) {
600
- const targetR = 255, targetG = 245, targetB = 240;
601
- r = Math.min(255, Math.max(0, Math.round(r + interferenceBoost * (targetR - r))));
602
- g = Math.min(255, Math.max(0, Math.round(g + interferenceBoost * (targetG - g))));
603
- b = Math.min(255, Math.max(0, Math.round(b + interferenceBoost * (targetB - b))));
604
- }
605
- return `\x1b[38;2;${r};${g};${b}m`;
606
- }
607
- return config.color;
608
- }
609
- export function applyRipples(text, ripples, now, config, targetText, resolvedMask, pulseIntensity) {
610
- if (!ripples.length && !targetText)
611
- return text;
612
- const len = Math.max(text.length, targetText?.length || 0);
613
- if (len === 0)
614
- return text;
615
- // Active ripples + recently-expired ripples for afterglow
616
- const activeRipples = ripples.filter(r => r.time <= now && now - r.time < r.dur);
617
- const afterglowRipples = ripples.filter(r => r.time <= now && now - r.time >= r.dur && now - r.time < r.dur + (r.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS));
618
- const activeCount = activeRipples.length;
619
- const afterglowCount = afterglowRipples.length;
620
- if (!activeCount && !afterglowCount && !targetText)
621
- return text;
622
- // Pre-compute radius per active ripple
623
- const radii = new Float64Array(activeCount);
624
- const leftBounds = new Int32Array(activeCount);
625
- const rightBounds = new Int32Array(activeCount);
626
- for (let i = 0; i < activeCount; i++) {
627
- const r = activeRipples[i];
628
- const elapsed = Math.min(1, (now - r.time) / r.dur);
629
- const maxDist = Math.max(r.pos, len - r.pos - 1);
630
- radii[i] = easeOutCubic(elapsed) * maxDist * r.spread;
631
- leftBounds[i] = Math.max(0, Math.floor(r.pos - radii[i]));
632
- rightBounds[i] = Math.min(len - 1, Math.ceil(r.pos + radii[i]));
633
- }
634
- // Pre-compute afterglow reach per expired ripple
635
- const afterglowData = afterglowCount > 0 ? afterglowRipples.map(r => ({
636
- pos: r.pos,
637
- maxReach: Math.max(r.pos, len - r.pos - 1) * r.spread,
638
- timeSinceExpiry: now - r.time - r.dur,
639
- })) : [];
640
- let segments = getSegmentBuffer(len * 3);
641
- let segCount = 0;
642
- let inColor = false;
643
- let currentPrefix = '';
644
- for (let idx = 0; idx < len; idx++) {
645
- const origChar = text[idx];
646
- if (origChar === ' ') {
647
- if (inColor) {
648
- segments[segCount++] = config ? ILLUMINATE_CLOSE : RESET_COLOR + DIM_OFF;
649
- inColor = false;
650
- currentPrefix = '';
651
- }
652
- segments[segCount++] = origChar;
653
- continue;
654
- }
655
- let maxDepth = 0;
656
- let combinedDepth = 0; // Additive depth for wave interference
657
- let afterglowIntensity = 0;
658
- let bestAgIdx = -1;
659
- let bestElapsed = 0;
660
- let bestDist = 0;
661
- let bestDur = activeRipples[0]?.dur ?? 0;
662
- let bestIdx = 0;
663
- for (let i = 0; i < activeCount; i++) {
664
- if (idx < leftBounds[i] || idx > rightBounds[i])
665
- continue;
666
- const dist = Math.abs(idx - activeRipples[i].pos);
667
- const depth = radii[i] - dist;
668
- if (depth > 0) {
669
- const fade = 1 - smoothstep(DEPTH_BAND_MAX - 0.5, DEPTH_BAND_MAX + 0.5, depth);
670
- if (fade > 0) {
671
- const cappedDepth = Math.min(depth, DEPTH_BAND_MAX);
672
- combinedDepth += cappedDepth * fade; // Additive for interference
673
- if (cappedDepth > maxDepth || (cappedDepth === maxDepth && activeRipples[i].time > activeRipples[bestIdx]?.time)) {
674
- maxDepth = cappedDepth;
675
- bestElapsed = now - activeRipples[i].time;
676
- bestDist = dist;
677
- bestDur = activeRipples[i].dur;
678
- bestIdx = i;
679
- }
680
- }
681
- }
682
- }
683
- // Cap combined depth to avoid overflow in color computation
684
- combinedDepth = Math.min(combinedDepth, DEPTH_BAND_MAX * 2);
685
- // Check recently-expired ripples for trailing afterglow (primary + secondary layers)
686
- if (maxDepth === 0) {
687
- for (let i = 0; i < afterglowCount; i++) {
688
- const dist = Math.abs(idx - afterglowData[i].pos);
689
- if (dist < afterglowData[i].maxReach) {
690
- const primaryAg = 1 - Math.min(1, afterglowData[i].timeSinceExpiry / 350);
691
- const secondaryAg = 0.4 * (1 - Math.min(1, afterglowData[i].timeSinceExpiry / AFTERGLOW_MS));
692
- if (primaryAg > afterglowIntensity || secondaryAg > afterglowIntensity) {
693
- bestAgIdx = i;
694
- }
695
- afterglowIntensity = Math.max(afterglowIntensity, primaryAg, secondaryAg);
696
- }
697
- }
698
- }
699
- if (maxDepth > 0) {
700
- const seed = activeRipples[bestIdx].seed ?? 0;
701
- const jitterTick = Math.floor(now / 42);
702
- const depthJitter = (hashNoise(seed, bestDist, jitterTick, 99) * 2 - 1) * 0.15;
703
- const jitteredDepth = Math.max(0.1, maxDepth + depthJitter);
704
- const char = (config?.scramble === false) ? origChar : selectScrambleChar(jitteredDepth, bestDist, bestElapsed, seed, text.length);
705
- if (config) {
706
- const crestDepth = radii[bestIdx] - bestDist;
707
- const isCrest = !config.crestOnly || (crestDepth > 0 && crestDepth < 2.0);
708
- let prefix = '';
709
- if (isCrest) {
710
- prefix = illuminatePrefix(maxDepth, bestElapsed, bestDur, config, combinedDepth);
711
- if (config.color === 'dynamic' && crestDepth > 0 && crestDepth < 1.5) {
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`;
718
- }
719
- }
720
- if (prefix) {
721
- if (!inColor || currentPrefix !== prefix) {
722
- if (inColor)
723
- segments[segCount++] = ILLUMINATE_CLOSE;
724
- segments[segCount++] = prefix;
725
- inColor = true;
726
- currentPrefix = prefix;
727
- }
728
- }
729
- else if (inColor) {
730
- segments[segCount++] = ILLUMINATE_CLOSE;
731
- inColor = false;
732
- currentPrefix = '';
733
- }
734
- segments[segCount++] = char;
735
- }
736
- else {
737
- if (inColor) {
738
- segments[segCount++] = ILLUMINATE_CLOSE;
739
- inColor = false;
740
- currentPrefix = '';
741
- }
742
- segments[segCount++] = char;
743
- }
744
- }
745
- else if (afterglowIntensity > 0) {
746
- const agRipple = afterglowRipples[bestAgIdx];
747
- const timeSinceExpiry = now - agRipple.time - agRipple.dur;
748
- // Discrete post-ripple glitch pops: 3 brief bursts after ripple expires
749
- const popWidth = 40;
750
- const popGap = 60;
751
- const inInitialPopWindow = (timeSinceExpiry >= 0 && timeSinceExpiry < popWidth)
752
- || (timeSinceExpiry >= popWidth + popGap && timeSinceExpiry < 2 * popWidth + popGap)
753
- || (timeSinceExpiry >= 2 * (popWidth + popGap) && timeSinceExpiry < 2 * (popWidth + popGap) + popWidth);
754
- const agTick = Math.floor(now / 40);
755
- const glitchRoll = bestAgIdx >= 0 ? hashNoise(agRipple.seed ?? 0, idx, agTick, 77) : 1;
756
- const popTarget = Math.min(0.045, 4 / Math.max(1, text.length));
757
- const shouldScramble = inInitialPopWindow && bestAgIdx >= 0 && afterglowRipples[bestAgIdx].dur >= 210 && glitchRoll < popTarget;
758
- if (shouldScramble && config?.scramble !== false) {
759
- if (config) {
760
- let agPrefix;
761
- if (config.color === 'dynamic') {
762
- // Cooling ember: warm at start, fading to dim cool
763
- // Echo pops get minimum intensity so chars stay visible long after ripple
764
- const effectiveIntensity = afterglowIntensity;
765
- const emberR = Math.round(200 + 55 * effectiveIntensity);
766
- const emberG = Math.round(130 + 80 * effectiveIntensity);
767
- const emberB = Math.round(140 + 70 * effectiveIntensity);
768
- agPrefix = `\x1b[38;2;${emberR};${emberG};${emberB}m`;
769
- }
770
- else {
771
- agPrefix = config.color;
772
- }
773
- if (!inColor || currentPrefix !== agPrefix) {
774
- if (inColor)
775
- segments[segCount++] = ILLUMINATE_CLOSE;
776
- segments[segCount++] = agPrefix;
777
- inColor = true;
778
- currentPrefix = agPrefix;
779
- }
780
- }
781
- const agDepth = afterglowIntensity * 4.5;
782
- const agElapsed = now - agRipple.time - agRipple.dur;
783
- const useSpark = config?.spark !== false;
784
- const char = useSpark
785
- ? selectSparkChar(agRipple.seed ?? 0, idx, agTick)
786
- : selectScrambleChar(agDepth, 0, agElapsed, agRipple.seed, text.length);
787
- segments[segCount++] = char;
788
- }
789
- else {
790
- // Plain afterglow — close any open styling and render origChar
791
- if (inColor) {
792
- segments[segCount++] = ILLUMINATE_CLOSE;
793
- inColor = false;
794
- currentPrefix = '';
795
- }
796
- segments[segCount++] = origChar;
797
- }
798
- }
799
- else {
800
- if (inColor) {
801
- segments[segCount++] = ILLUMINATE_CLOSE;
802
- inColor = false;
803
- currentPrefix = '';
804
- }
805
- if (pulseIntensity !== undefined) {
806
- const settleTick = Math.floor(now / 175);
807
- const settleRoll = hashNoise(42, idx, settleTick, 33);
808
- if (settleRoll < 0.05) {
809
- const settlePrefix = (hashNoise(42, idx, settleTick, 55) < 0.5)
810
- ? '\x1b[38;2;80;170;255m' // sky
811
- : '\x1b[38;2;255;140;120m'; // warm
812
- if (!inColor || currentPrefix !== settlePrefix) {
813
- if (inColor)
814
- segments[segCount++] = ILLUMINATE_CLOSE;
815
- segments[segCount++] = settlePrefix;
816
- inColor = true;
817
- currentPrefix = settlePrefix;
818
- }
819
- }
820
- }
821
- segments[segCount++] = origChar;
822
- }
823
- }
824
- if (inColor) {
825
- segments[segCount++] = ILLUMINATE_CLOSE;
826
- }
827
- return segments.slice(0, segCount).join('');
828
- }
829
- function spawnRipple(pos, now, dur = RIPPLE_DUR_DEFAULT, spread = RIPPLE_SPREAD_DEFAULT, seed, contentChange) {
830
- const jitteredDur = Math.round(dur * (0.9 + Math.random() * 0.2));
831
- return { pos, time: now, dur: jitteredDur, spread, seed: seed ?? makeAnimationSeed(String(pos), now), contentChange };
832
- }
833
- function spawnIlluminateRipple(pos, now, config, seed, contentChange) {
834
- const jitteredDur = Math.round(config.duration * (0.9 + Math.random() * 0.2));
835
- return { pos, time: now - (config.initialTimeOffset || 0), dur: jitteredDur, spread: config.spread, seed: seed ?? makeAnimationSeed(String(pos), now), contentChange };
836
- }
837
- function getRippleDuration(textLength, baseDur = RIPPLE_DUR_DEFAULT) {
838
- if (textLength <= 5)
839
- return Math.max(baseDur, 950);
840
- if (textLength <= 10)
841
- return Math.max(baseDur, 850);
842
- return baseDur;
843
- }
844
- function spawnSecondaryRipple(primary) {
845
- const delay = Math.max(0, Math.min(SECONDARY_RIPPLE_DELAY_MS, primary.dur * 0.4) + (Math.random() * 40 - 20));
846
- return {
847
- ...primary,
848
- time: primary.time + delay,
849
- dur: primary.dur * 0.6,
850
- spread: primary.spread * SECONDARY_RIPPLE_STRENGTH,
851
- seed: (primary.seed ?? 0) + 1,
852
- contentChange: primary.contentChange,
853
- };
854
- }
855
- function spawnRippleForText(pos, now, textLength, seed, contentChange) {
856
- const primary = spawnRipple(pos, now, getRippleDuration(textLength), RIPPLE_SPREAD_DEFAULT, seed, contentChange);
857
- return [primary, spawnSecondaryRipple(primary)];
858
- }
859
- function spawnIlluminateRippleForText(pos, now, config, textLength, seed, contentChange) {
860
- // Illuminate labels use intentional per-config durations (400ms for labels, 1200ms for content)
861
- // Skip getRippleDuration floor which forces short text to 1150-1300ms — that's meant for streaming content, not tool labels
862
- const dur = config.duration;
863
- const primary = spawnIlluminateRipple(pos, now, { ...config, duration: dur }, seed, contentChange);
864
- return [primary, spawnSecondaryRipple(primary)];
865
- }
866
- /**
867
- * Compute a ripple spawn center with random jitter.
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.
871
- */
872
- function randomizedCenter(length, jitterRatio, rng) {
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;
880
- }
881
- /**
882
- * Find sentence-start character positions in text.
883
- * Returns positions of the first non-space character after sentence
884
- * delimiters (. ! ? ... \n) plus position 0. If fewer than 2
885
- * positions are found, falls back to positions at ~30-char intervals.
886
- */
887
- export function findSentenceStarts(text) {
888
- const starts = [];
889
- if (text.length === 0)
890
- return starts;
891
- starts.push(0);
892
- const delimiters = ['... ', '. ', '! ', '? ', '\n'];
893
- let i = 0;
894
- while (i < text.length) {
895
- let bestD = '';
896
- let bestLen = 0;
897
- for (const d of delimiters) {
898
- if (text.slice(i, i + d.length) === d && d.length > bestLen) {
899
- bestD = d;
900
- bestLen = d.length;
901
- }
902
- }
903
- if (bestD) {
904
- let pos = i + bestD.length;
905
- while (pos < text.length && text[pos] === ' ')
906
- pos++;
907
- if (pos < text.length && pos !== starts[starts.length - 1]) {
908
- starts.push(pos);
909
- }
910
- i = pos;
911
- }
912
- else {
913
- i++;
914
- }
915
- }
916
- // Fallback: if too few sentence starts, add positions at ~30-char intervals
917
- if (starts.length < 2 && text.length > 30) {
918
- const stride = Math.max(30, Math.floor(text.length / 3));
919
- let pos = stride;
920
- while (pos < text.length) {
921
- while (pos < text.length && text[pos] === ' ')
922
- pos++;
923
- if (pos < text.length && !starts.includes(pos)) {
924
- starts.push(pos);
925
- }
926
- pos += stride;
927
- }
928
- }
929
- return starts;
930
- }
931
- /**
932
- * Pick a random sentence-start position. Falls back to `randomizedCenter`
933
- * when the text has no sentence boundaries.
934
- */
935
- export function randomSentenceStart(text, rng) {
936
- const starts = findSentenceStarts(text);
937
- if (starts.length === 0 || (starts.length === 1 && starts[0] === 0)) {
938
- return randomizedCenter(text.length, 0.2, rng);
939
- }
940
- const idx = rng ? rng.nextInt(starts.length) : Math.floor(Math.random() * starts.length);
941
- return starts[idx];
942
- }
943
- // ---------------------------------------------------------------------------
944
- // Unified apply function (cascade/ripple/illuminate)
945
- // ---------------------------------------------------------------------------
946
- function computePulseIntensity(state, now) {
947
- const hasActive = state.ripples.some(r => now - r.time < r.dur);
948
- if (!hasActive) {
949
- if (state.lastRippleEndTime === 0 && state.ripples.length > 0) {
950
- state.lastRippleEndTime = now;
951
- }
952
- }
953
- else {
954
- state.lastRippleEndTime = 0;
955
- }
956
- if (state.lastRippleEndTime > 0) {
957
- const timeSinceEnd = now - state.lastRippleEndTime;
958
- if (timeSinceEnd < PULSE_WINDOW_MS) {
959
- return 0.5; // Steady constant — no intensity oscillation
960
- }
961
- state.lastRippleEndTime = 0;
962
- }
963
- return undefined;
964
- }
965
- function applyScramble(text, state, now, mode, lineKey, rng) {
966
- if (mode === 'cascade') {
967
- if (!state.queue.length)
968
- return state.displayedText || text;
969
- const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
970
- if (isCascadeComplete(state.queue, frame, state.queueMaxEnd)) {
971
- state.queue = [];
972
- return state.displayedText || text;
973
- }
974
- return computeCascadeFrame(state.queue, frame, rng);
975
- }
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
- }
986
- const config = lineKey === 'msg'
987
- ? ILLUMINATE_CONFIGS.msgContent
988
- : lineKey === 'act'
989
- ? ILLUMINATE_CONFIGS.actLabel
990
- : undefined;
991
- const pulseIntensity = computePulseIntensity(state, now);
992
- return applyRipples(text, state.ripples, now, config, undefined, undefined, pulseIntensity);
993
- }
994
- else {
995
- const pulseIntensity = computePulseIntensity(state, now);
996
- return applyRipples(text, state.ripples, now, undefined, undefined, undefined, pulseIntensity);
997
- }
998
- }
999
- // ---------------------------------------------------------------------------
1000
- // processLine — unified change detection (cascade/ripple)
1001
- // ---------------------------------------------------------------------------
1002
- function processLine(state, newText, now, mode, lineKey) {
1003
- if (state.completed)
1004
- return;
1005
- // Illuminate mode: debounce-based stable ripple for msg:, immediate for act:/aim:
1006
- if (mode === 'illuminate') {
1007
- if (!state.initialized) {
1008
- state.lastText = newText;
1009
- state.initialized = true;
1010
- if (lineKey === 'msg') {
1011
- state.displayedText = newText;
1012
- state.lastFlushTime = now;
1013
- state.lastTextChangeTime = now;
1014
- }
1015
- else {
1016
- state.displayedText = newText;
1017
- state.lastFlushTime = now;
1018
- state.lastAnimTime = now;
1019
- }
1020
- return;
1021
- }
1022
- // msg: content — chunk-based ripple (plain while buffering, ripple on chunk threshold)
1023
- if (lineKey === 'msg') {
1024
- const textChanged = state.lastText !== newText;
1025
- // Clean up expired ripples (keep within afterglow window)
1026
- let keep = 0;
1027
- for (let i = 0; i < state.ripples.length; i++) {
1028
- if (now - state.ripples[i].time < state.ripples[i].dur + (state.ripples[i].contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS)) {
1029
- state.ripples[keep++] = state.ripples[i];
1030
- }
1031
- }
1032
- state.ripples.length = keep;
1033
- const hasActiveRipples = state.ripples.some(r => now - r.time < r.dur);
1034
- const gap = now - state.lastTextChangeTime;
1035
- const glitchCooledDown = now - state.lastGlitchTime >= GLITCH_COOLDOWN_MS;
1036
- const previousText = state.lastText;
1037
- if (textChanged) {
1038
- const delta = Math.max(0, newText.length - state.lastText.length);
1039
- state.lastText = newText;
1040
- state.phraseBuffer = newText;
1041
- state.lastTextChangeTime = now;
1042
- state.charsSinceLastFlush += delta;
1043
- }
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;
1047
- state.displayedText = newText;
1048
- state.lastFlushTime = now;
1049
- state.lastAnimTime = now;
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
- }
1058
- }
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) {
1074
- // Drain: text stopped arriving and we have unrippled content —
1075
- // glitch it out so it doesn't sit plain indefinitely.
1076
- const oldDisplayed = previousText || state.displayedText;
1077
- state.displayedText = newText;
1078
- state.lastFlushTime = now;
1079
- state.lastAnimTime = now;
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
- }
1088
- }
1089
- else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && newText !== state.displayedText && gap > STREAMING_RESUME_GAP_MS) {
1090
- // Streaming resumed after a long pause (e.g., tool call) —
1091
- // force a fresh glitch on the accumulated content.
1092
- const oldDisplayed = previousText || state.displayedText;
1093
- state.displayedText = newText;
1094
- state.lastFlushTime = now;
1095
- state.lastAnimTime = now;
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
- }
1104
- }
1105
- return;
1106
- }
1107
- // act: and aim: — glitch animation
1108
- if (state.lastText === newText) {
1109
- return;
1110
- }
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) {
1120
- state.lastText = newText;
1121
- return;
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));
1125
- const cooledDown = now - state.lastAnimTime >= MIN_RIPPLE_INTERVAL;
1126
- if (!cooledDown && !hadRipples) {
1127
- state.lastText = newText;
1128
- return;
1129
- }
1130
- const oldDisplayed = state.displayedText;
1131
- state.displayedText = newText;
1132
- state.lastText = newText;
1133
- state.lastFlushTime = now;
1134
- state.lastAnimTime = now;
1135
- state.glitchQueue = buildGlitchQueue(oldDisplayed || '', newText);
1136
- state.startTime = now;
1137
- state.glitchFrame = 0;
1138
- state.lastGlitchTime = now;
1139
- state.ripples = [];
1140
- return;
1141
- }
1142
- // Standard modes (stream/cascade/ripple)
1143
- const textChanged = state.lastText !== newText;
1144
- if (!state.initialized) {
1145
- state.lastText = newText;
1146
- state.initialized = true;
1147
- state.lastAnimTime = now;
1148
- if (mode === 'cascade') {
1149
- state.queue = buildQueue('', newText);
1150
- state.startTime = now;
1151
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1152
- }
1153
- else if (mode === 'ripple') {
1154
- state.ripples.push(...spawnRippleForText(randomizedCenter(newText.length), now, newText.length, undefined, lineKey === 'msg'));
1155
- }
1156
- return;
1157
- }
1158
- if (!textChanged)
1159
- return;
1160
- const oldText = state.lastText;
1161
- // Detect tail-view slides: if old suffix matches new prefix significantly,
1162
- // the visible window is just sliding — don't restart animation.
1163
- const overlap = computeOverlapLen(oldText, newText);
1164
- const minLen = Math.min(oldText.length, newText.length);
1165
- const isExtension = newText.startsWith(oldText);
1166
- if (!isExtension && overlap > 0 && overlap >= minLen * 0.5) {
1167
- state.lastText = newText;
1168
- state.displayedText = newText;
1169
- return;
1170
- }
1171
- const cooledDown = now - state.lastAnimTime >= MIN_RIPPLE_INTERVAL;
1172
- state.lastText = newText;
1173
- if (cooledDown) {
1174
- state.displayedText = newText;
1175
- state.lastAnimTime = now;
1176
- if (mode === 'cascade') {
1177
- state.queue = buildQueue(oldText, newText);
1178
- state.startTime = now;
1179
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1180
- }
1181
- else {
1182
- state.ripples.push(...spawnRippleForText(randomizedCenter(newText.length), now, newText.length, undefined, lineKey === 'msg'));
1183
- }
1184
- }
1185
- if (mode === 'ripple') {
1186
- let keep = 0;
1187
- for (let i = 0; i < state.ripples.length; i++) {
1188
- if (now - state.ripples[i].time < state.ripples[i].dur + (state.ripples[i].contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS)) {
1189
- state.ripples[keep++] = state.ripples[i];
1190
- }
1191
- }
1192
- state.ripples.length = keep;
1193
- }
1194
- }
1195
- // ---------------------------------------------------------------------------
1196
- // ScrambleStateManager
1197
- // ---------------------------------------------------------------------------
1198
- function createLineState() {
1199
- return {
1200
- lastText: '',
1201
- queue: [],
1202
- queueMaxEnd: 0,
1203
- startTime: 0,
1204
- ripples: [],
1205
- lastAnimTime: 0,
1206
- initialized: false,
1207
- completed: false,
1208
- phraseBuffer: '',
1209
- displayedText: '',
1210
- pendingText: '',
1211
- lastFlushTime: 0,
1212
- targetText: '',
1213
- resolvedMask: new Set(),
1214
- lastAccessTime: Date.now(),
1215
- lastTextChangeTime: 0,
1216
- lastRippleEndTime: 0,
1217
- charsSinceLastFlush: 0,
1218
- glitchQueue: [],
1219
- glitchFrame: 0,
1220
- lastGlitchTime: 0,
1221
- };
1222
- }
1223
- function createValueFlashState() {
1224
- return { prev: '', ripples: [], queue: [], queueMaxEnd: 0, startTime: 0, lastValueChangeTime: 0, lastFlashTime: 0, completed: false, lastRippleEndTime: 0, glitchQueue: [], glitchFrame: 0, lastGlitchTime: 0 };
1225
- }
1226
- function createTypewriterState(speed) {
1227
- return {
1228
- fullText: '',
1229
- revealedCount: 0,
1230
- lastRevealTime: 0,
1231
- speed,
1232
- scrambleWidth: STREAM_SCRAMBLE_WIDTH,
1233
- completed: false,
1234
- cursorChars: [],
1235
- lastVisibleText: '',
1236
- };
1237
- }
1238
- /**
1239
- * Compute the longest suffix of `oldStr` that matches a prefix of `newStr`.
1240
- * Used for tail-view window sliding: when the visible text shifts, we want
1241
- * to know how many chars from the old view are still present at the start
1242
- * of the new view so revealedCount can be adjusted smoothly.
1243
- */
1244
- function computeOverlapLen(oldStr, newStr) {
1245
- const maxOverlap = Math.min(oldStr.length, newStr.length);
1246
- if (maxOverlap === 0)
1247
- return 0;
1248
- // KMP LPS array for newStr prefix of length maxOverlap
1249
- const lps = new Array(maxOverlap).fill(0);
1250
- let len = 0;
1251
- for (let i = 1; i < maxOverlap; i++) {
1252
- while (len > 0 && newStr[i] !== newStr[len]) {
1253
- len = lps[len - 1];
1254
- }
1255
- if (newStr[i] === newStr[len])
1256
- len++;
1257
- lps[i] = len;
1258
- }
1259
- // Match newStr prefix against oldStr suffix
1260
- len = 0;
1261
- const startIdx = Math.max(0, oldStr.length - maxOverlap);
1262
- for (let i = startIdx; i < oldStr.length; i++) {
1263
- while (len > 0 && oldStr[i] !== newStr[len]) {
1264
- len = lps[len - 1];
1265
- }
1266
- if (oldStr[i] === newStr[len])
1267
- len++;
1268
- }
1269
- return len;
1270
- }
1271
- /**
1272
- * For static lines, detect whether a text change is a minor mutation
1273
- * (most characters remain in the same positions). Used to suppress
1274
- * re-flashing when embedded stats (TPS, tokens) change at the end of
1275
- * a header line while the prefix (flow name, model) stays stable.
1276
- */
1277
- function isMinorStaticMutation(oldStr, newStr) {
1278
- const maxLen = Math.max(oldStr.length, newStr.length);
1279
- if (maxLen === 0)
1280
- return true;
1281
- let same = 0;
1282
- const minLen = Math.min(oldStr.length, newStr.length);
1283
- for (let i = 0; i < minLen; i++) {
1284
- if (oldStr[i] === newStr[i])
1285
- same++;
1286
- }
1287
- return same / maxLen >= 0.5;
1288
- }
1289
- const MAX_FLOW_ENTRIES = 128;
1290
- const MAX_CACHE_AGE_MS = 5 * 60 * 1000; // 5 minutes
1291
- export class ScrambleStateManager {
1292
- static VALID_MODES = ['stream', 'cascade', 'ripple', 'illuminate'];
1293
- mode = DEFAULT_MODE;
1294
- cache = new Map();
1295
- tpsState = new Map();
1296
- actKpiState = new Map();
1297
- msgKpiState = new Map();
1298
- streamState = new Map();
1299
- genericCache = new Map();
1300
- randomPool = [];
1301
- randomPoolIndex = 0;
1302
- fillRandomPool() {
1303
- this.randomPool = new Array(RANDOM_POOL_SIZE);
1304
- for (let i = 0; i < RANDOM_POOL_SIZE; i++) {
1305
- this.randomPool[i] = SCRAMBLE_CHARS[Math.floor(Math.random() * SCRAMBLE_CHARS.length)];
1306
- }
1307
- this.randomPoolIndex = 0;
1308
- }
1309
- poolRandomChar() {
1310
- if (this.randomPoolIndex >= this.randomPool.length - POOL_REFILL_THRESHOLD) {
1311
- this.fillRandomPool();
1312
- }
1313
- return this.randomPool[this.randomPoolIndex++];
1314
- }
1315
- setMode(mode) {
1316
- if (!ScrambleStateManager.VALID_MODES.includes(mode)) {
1317
- throw new Error(`Invalid scramble mode: ${mode}. Expected one of: ${ScrambleStateManager.VALID_MODES.join(', ')}`);
1318
- }
1319
- this.mode = mode;
1320
- this.clear();
1321
- }
1322
- getMode() {
1323
- return this.mode;
1324
- }
1325
- getState(id, key) {
1326
- let record = this.cache.get(id);
1327
- if (!record) {
1328
- record = { aim: createLineState(), act: createLineState(), msg: createLineState() };
1329
- this.cache.set(id, record);
1330
- }
1331
- return record[key];
1332
- }
1333
- getStreamState(id, key) {
1334
- let record = this.streamState.get(id);
1335
- if (!record) {
1336
- record = { msg: createTypewriterState(STREAM_SPEED_MSG), act: createTypewriterState(STREAM_SPEED_ACT) };
1337
- this.streamState.set(id, record);
1338
- }
1339
- return record[key];
1340
- }
1341
- // -----------------------------------------------------------------------
1342
- // Generic text animation (any key, any text)
1343
- // -----------------------------------------------------------------------
1344
- getGenericState(id, key, now) {
1345
- const cacheKey = `${id}#${key}`;
1346
- let state = this.genericCache.get(cacheKey);
1347
- if (!state) {
1348
- state = createLineState();
1349
- this.genericCache.set(cacheKey, state);
1350
- }
1351
- state.lastAccessTime = now;
1352
- return state;
1353
- }
1354
- updateText(id, key, text, now, isComplete = false, staticLine = false) {
1355
- if (isComplete) {
1356
- const state = this.genericCache.get(`${id}#${key}`);
1357
- if (!state)
1358
- return { label: key, content: text, isAnimating: false };
1359
- }
1360
- const state = this.getGenericState(id, key, now);
1361
- // Reset if a previously-completed flow is now running again
1362
- if (!isComplete && state.completed) {
1363
- state.completed = false;
1364
- state.queue = [];
1365
- state.ripples = [];
1366
- state.lastText = '';
1367
- state.initialized = false;
1368
- state.phraseBuffer = '';
1369
- state.displayedText = '';
1370
- state.pendingText = '';
1371
- state.lastFlushTime = 0;
1372
- state.lastRippleEndTime = 0;
1373
- state.charsSinceLastFlush = 0;
1374
- state.glitchQueue = [];
1375
- state.glitchFrame = 0;
1376
- }
1377
- if (isComplete) {
1378
- state.completed = true;
1379
- state.queue = [];
1380
- state.ripples = [];
1381
- state.glitchQueue = [];
1382
- state.glitchFrame = 0;
1383
- }
1384
- if (state.completed)
1385
- return { label: key, content: text, isAnimating: false };
1386
- // Trigger initial reveal animation for static text (non-stream modes)
1387
- if (!state.initialized && this.mode !== 'stream') {
1388
- state.lastText = text;
1389
- state.initialized = true;
1390
- state.lastAnimTime = now;
1391
- if (this.mode === 'cascade') {
1392
- state.queue = buildQueue('', text);
1393
- state.startTime = now;
1394
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1395
- }
1396
- else if (this.mode === 'illuminate') {
1397
- state.glitchQueue = buildGlitchQueue('', text);
1398
- state.startTime = now;
1399
- state.lastGlitchTime = now;
1400
- state.glitchFrame = 0;
1401
- }
1402
- else {
1403
- state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, true));
1404
- }
1405
- }
1406
- else if (staticLine && state.initialized) {
1407
- const oldText = state.lastText;
1408
- const textChanged = oldText !== text;
1409
- state.lastText = text;
1410
- if (this.mode === 'illuminate') {
1411
- state.displayedText = text;
1412
- state.pendingText = '';
1413
- }
1414
- if (textChanged) {
1415
- if (isMinorStaticMutation(oldText, text)) {
1416
- // minor mutation (e.g. trailing stat digit) — don't restart animation
1417
- }
1418
- else if (now - state.lastAnimTime >= MIN_RIPPLE_INTERVAL) {
1419
- state.lastAnimTime = now;
1420
- if (this.mode === 'cascade') {
1421
- state.queue = buildQueue('', text);
1422
- state.startTime = now;
1423
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1424
- }
1425
- else if (this.mode === 'illuminate') {
1426
- state.ripples = [];
1427
- state.glitchQueue = buildGlitchQueue(state.displayedText || '', text);
1428
- state.startTime = now;
1429
- state.lastGlitchTime = now;
1430
- state.glitchFrame = 0;
1431
- }
1432
- else {
1433
- state.ripples = [];
1434
- state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, true));
1435
- }
1436
- }
1437
- }
1438
- }
1439
- else {
1440
- processLine(state, text, now, this.mode);
1441
- }
1442
- const content = applyScramble(text, state, now, this.mode, undefined, () => this.poolRandomChar());
1443
- const isAnimating = this.isLineAnimating(state, now);
1444
- return { label: key, content, isAnimating };
1445
- }
1446
- // -----------------------------------------------------------------------
1447
- // aim: — cascade/ripple/illuminate on text change
1448
- // -----------------------------------------------------------------------
1449
- updateAim(id, text, now, isComplete = false, staticLine = false) {
1450
- if (isComplete) {
1451
- const record = this.cache.get(id);
1452
- if (!record)
1453
- return { label: 'aim:', content: text, isAnimating: false };
1454
- }
1455
- const state = this.getState(id, 'aim');
1456
- // Reset if a previously-completed flow is now running again (new flow started)
1457
- if (!isComplete && state.completed) {
1458
- state.completed = false;
1459
- state.queue = [];
1460
- state.ripples = [];
1461
- state.lastText = '';
1462
- state.initialized = false;
1463
- state.phraseBuffer = '';
1464
- state.displayedText = '';
1465
- state.pendingText = '';
1466
- state.lastFlushTime = 0;
1467
- state.lastRippleEndTime = 0;
1468
- state.charsSinceLastFlush = 0;
1469
- state.glitchQueue = [];
1470
- state.glitchFrame = 0;
1471
- }
1472
- if (isComplete) {
1473
- state.completed = true;
1474
- state.queue = [];
1475
- state.ripples = [];
1476
- state.glitchQueue = [];
1477
- state.glitchFrame = 0;
1478
- }
1479
- if (state.completed)
1480
- return { label: 'aim:', content: text, isAnimating: false };
1481
- // Stream mode: aim is static text, no typewriter animation
1482
- if (this.mode === 'stream') {
1483
- return { label: 'aim:', content: text, isAnimating: false };
1484
- }
1485
- // Trigger initial reveal animation for aim on first call
1486
- if (!state.initialized) {
1487
- state.lastText = text;
1488
- state.initialized = true;
1489
- state.lastAnimTime = now;
1490
- if (this.mode === 'cascade') {
1491
- state.queue = buildQueue('', text);
1492
- state.startTime = now;
1493
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1494
- }
1495
- else if (this.mode === 'illuminate') {
1496
- state.glitchQueue = buildGlitchQueue('', text);
1497
- state.startTime = now;
1498
- state.lastGlitchTime = now;
1499
- state.glitchFrame = 0;
1500
- }
1501
- else {
1502
- state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, false));
1503
- }
1504
- }
1505
- else if (staticLine && state.initialized) {
1506
- const oldText = state.lastText;
1507
- const textChanged = oldText !== text;
1508
- state.lastText = text;
1509
- if (this.mode === 'illuminate') {
1510
- state.displayedText = text;
1511
- state.pendingText = '';
1512
- }
1513
- if (textChanged) {
1514
- if (isMinorStaticMutation(oldText, text)) {
1515
- // minor mutation — don't restart animation
1516
- }
1517
- else if (now - state.lastAnimTime >= MIN_RIPPLE_INTERVAL) {
1518
- state.lastAnimTime = now;
1519
- if (this.mode === 'cascade') {
1520
- state.queue = buildQueue('', text);
1521
- state.startTime = now;
1522
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1523
- }
1524
- else if (this.mode === 'illuminate') {
1525
- state.ripples = [];
1526
- state.glitchQueue = buildGlitchQueue(state.displayedText || '', text);
1527
- state.startTime = now;
1528
- state.lastGlitchTime = now;
1529
- state.glitchFrame = 0;
1530
- }
1531
- else {
1532
- state.ripples = [];
1533
- state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, false));
1534
- }
1535
- }
1536
- }
1537
- else if (!this.isLineAnimating(state, now)) {
1538
- state.queue = [];
1539
- state.ripples = [];
1540
- state.glitchQueue = [];
1541
- state.glitchFrame = 0;
1542
- }
1543
- }
1544
- else {
1545
- processLine(state, text, now, this.mode);
1546
- }
1547
- const content = applyScramble(text, state, now, this.mode, undefined, () => this.poolRandomChar());
1548
- const isAnimating = this.isLineAnimating(state, now);
1549
- return { label: 'aim:', content, isAnimating };
1550
- }
1551
- // -----------------------------------------------------------------------
1552
- // act: — stream/cascade/ripple on text change
1553
- // -----------------------------------------------------------------------
1554
- updateAct(id, text, now, isComplete = false, staticLine = false) {
1555
- if (isComplete) {
1556
- const record = this.cache.get(id);
1557
- if (!record)
1558
- return { label: 'act:', content: text, isAnimating: false };
1559
- }
1560
- const state = this.getState(id, 'act');
1561
- // Reset if a previously-completed flow is now running again (new flow started)
1562
- if (!isComplete && state.completed) {
1563
- state.completed = false;
1564
- state.queue = [];
1565
- state.ripples = [];
1566
- state.lastText = '';
1567
- state.initialized = false;
1568
- state.phraseBuffer = '';
1569
- state.displayedText = '';
1570
- state.pendingText = '';
1571
- state.lastFlushTime = 0;
1572
- state.lastRippleEndTime = 0;
1573
- state.charsSinceLastFlush = 0;
1574
- state.glitchQueue = [];
1575
- state.glitchFrame = 0;
1576
- }
1577
- if (isComplete) {
1578
- state.completed = true;
1579
- state.queue = [];
1580
- state.ripples = [];
1581
- state.glitchQueue = [];
1582
- state.glitchFrame = 0;
1583
- }
1584
- if (state.completed)
1585
- return { label: 'act:', content: text, isAnimating: false };
1586
- if (!state.initialized) {
1587
- state.lastText = text;
1588
- state.initialized = true;
1589
- state.lastAnimTime = now;
1590
- if (this.mode === 'cascade') {
1591
- state.queue = buildQueue('', text);
1592
- state.startTime = now;
1593
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1594
- }
1595
- else if (this.mode === 'illuminate') {
1596
- state.glitchQueue = buildGlitchQueue('', text);
1597
- state.startTime = now;
1598
- state.lastGlitchTime = now;
1599
- state.glitchFrame = 0;
1600
- state.displayedText = text;
1601
- }
1602
- else {
1603
- state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, false));
1604
- }
1605
- }
1606
- else if (staticLine && state.initialized) {
1607
- const oldText = state.lastText;
1608
- const textChanged = oldText !== text;
1609
- state.lastText = text;
1610
- if (this.mode === 'illuminate') {
1611
- state.displayedText = text;
1612
- state.pendingText = '';
1613
- }
1614
- if (textChanged) {
1615
- if (isMinorStaticMutation(oldText, text)) {
1616
- // minor mutation — don't restart animation
1617
- }
1618
- else if (now - state.lastAnimTime >= MIN_RIPPLE_INTERVAL) {
1619
- state.lastAnimTime = now;
1620
- if (this.mode === 'cascade') {
1621
- state.queue = buildQueue('', text);
1622
- state.startTime = now;
1623
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1624
- }
1625
- else if (this.mode === 'illuminate') {
1626
- state.ripples = [];
1627
- state.glitchQueue = buildGlitchQueue(state.displayedText || '', text);
1628
- state.startTime = now;
1629
- state.lastGlitchTime = now;
1630
- state.glitchFrame = 0;
1631
- }
1632
- else {
1633
- state.ripples = [];
1634
- state.ripples.push(...spawnRippleForText(randomizedCenter(text.length), now, text.length, undefined, false));
1635
- }
1636
- }
1637
- }
1638
- else if (!this.isLineAnimating(state, now)) {
1639
- state.queue = [];
1640
- state.ripples = [];
1641
- state.glitchQueue = [];
1642
- state.glitchFrame = 0;
1643
- }
1644
- }
1645
- else {
1646
- processLine(state, text, now, this.mode, 'act');
1647
- }
1648
- const content = applyScramble(text, state, now, this.mode, 'act', () => this.poolRandomChar());
1649
- const isAnimating = this.isLineAnimating(state, now);
1650
- return { label: 'act:', content, isAnimating };
1651
- }
1652
- // -----------------------------------------------------------------------
1653
- // msg: — stream/cascade/ripple on text change
1654
- // -----------------------------------------------------------------------
1655
- updateMsg(id, text, now, isComplete = false, budget, staticLine = false) {
1656
- const visibleText = budget !== undefined ? tailText(text, budget) : text;
1657
- if (isComplete) {
1658
- const record = this.cache.get(id);
1659
- if (!record)
1660
- return { label: 'msg:', content: visibleText, isAnimating: false };
1661
- }
1662
- const state = this.getState(id, 'msg');
1663
- // Reset if a previously-completed flow is now running again (new flow started)
1664
- if (!isComplete && state.completed) {
1665
- state.completed = false;
1666
- state.queue = [];
1667
- state.ripples = [];
1668
- state.lastText = '';
1669
- state.initialized = false;
1670
- state.phraseBuffer = '';
1671
- state.displayedText = '';
1672
- state.pendingText = '';
1673
- state.lastFlushTime = 0;
1674
- state.lastRippleEndTime = 0;
1675
- state.glitchQueue = [];
1676
- state.glitchFrame = 0;
1677
- }
1678
- if (isComplete) {
1679
- state.completed = true;
1680
- state.queue = [];
1681
- state.ripples = [];
1682
- state.glitchQueue = [];
1683
- state.glitchFrame = 0;
1684
- }
1685
- if (state.completed)
1686
- return { label: 'msg:', content: visibleText, isAnimating: false };
1687
- if (!state.initialized) {
1688
- state.lastText = visibleText;
1689
- state.initialized = true;
1690
- state.lastFlushTime = now;
1691
- if (this.mode === 'cascade') {
1692
- state.displayedText = visibleText;
1693
- state.phraseBuffer = visibleText;
1694
- state.queue = buildQueue('', visibleText);
1695
- state.startTime = now;
1696
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1697
- state.lastAnimTime = now;
1698
- }
1699
- else if (this.mode === 'illuminate') {
1700
- state.displayedText = visibleText;
1701
- state.phraseBuffer = visibleText;
1702
- state.lastAnimTime = 0;
1703
- state.lastTextChangeTime = now;
1704
- }
1705
- else {
1706
- state.displayedText = visibleText;
1707
- state.phraseBuffer = visibleText;
1708
- state.ripples.push(...spawnRippleForText(randomizedCenter(visibleText.length), now, visibleText.length));
1709
- state.lastAnimTime = now;
1710
- }
1711
- }
1712
- else if (staticLine && state.initialized) {
1713
- const oldText = state.lastText;
1714
- const textChanged = oldText !== visibleText;
1715
- if (this.mode === 'stream') {
1716
- state.lastText = visibleText;
1717
- // stream mode: text displays directly, no buffering needed
1718
- }
1719
- else if (this.mode === 'illuminate') {
1720
- // Chunk-based ripple: plain text while buffering, ripple on chunk threshold
1721
- // Clean up expired ripples
1722
- state.ripples = state.ripples.filter(r => now - r.time < r.dur + (r.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS));
1723
- state.queue = [];
1724
- const hasActiveRipples = state.ripples.some(r => now - r.time < r.dur);
1725
- const gap = now - state.lastTextChangeTime;
1726
- const glitchCooledDown = now - state.lastGlitchTime >= GLITCH_COOLDOWN_MS;
1727
- const previousText = state.lastText;
1728
- if (textChanged) {
1729
- const delta = Math.max(0, visibleText.length - state.lastText.length);
1730
- state.lastText = visibleText;
1731
- state.phraseBuffer = visibleText;
1732
- state.lastTextChangeTime = now;
1733
- state.charsSinceLastFlush += delta;
1734
- }
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;
1738
- state.displayedText = visibleText;
1739
- state.lastFlushTime = now;
1740
- state.lastAnimTime = now;
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
- }
1749
- }
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) {
1765
- // Drain: text stopped arriving and we have unrippled content —
1766
- // glitch it out so it doesn't sit plain indefinitely.
1767
- const oldDisplayed = previousText || state.displayedText;
1768
- state.displayedText = visibleText;
1769
- state.lastFlushTime = now;
1770
- state.lastAnimTime = now;
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
- }
1779
- }
1780
- else if ((state.ripples.length < 6 || state.charsSinceLastFlush >= 80) && visibleText !== state.displayedText && gap > STREAMING_RESUME_GAP_MS) {
1781
- // Streaming resumed after a long pause (e.g., tool call) —
1782
- // force a fresh glitch on the accumulated content.
1783
- const oldDisplayed = previousText || state.displayedText;
1784
- state.displayedText = visibleText;
1785
- state.lastFlushTime = now;
1786
- state.lastAnimTime = now;
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
- }
1795
- }
1796
- }
1797
- else {
1798
- // Existing behavior for cascade and ripple modes
1799
- if (this.isLineAnimating(state, now)) {
1800
- // Animation active — suppress ALL text changes.
1801
- // Old text stays frozen on screen while the active ripple
1802
- // plays to completion. No overlapping ripples.
1803
- }
1804
- else {
1805
- // Animation NOT active — clean up expired ripples/queues
1806
- // and handle text changes with cooldown check.
1807
- const hadRipples = state.ripples.length > 0;
1808
- const hadActiveRipplesBefore = state.ripples.some(r => now - r.time < r.dur);
1809
- state.ripples = state.ripples.filter(r => now - r.time < r.dur + (r.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS));
1810
- state.queue = [];
1811
- state.glitchQueue = [];
1812
- state.glitchFrame = 0;
1813
- const justExpired = hadRipples && !hadActiveRipplesBefore;
1814
- if (!textChanged) {
1815
- if (state.displayedText !== visibleText) {
1816
- // Commit latest text without ripple
1817
- state.displayedText = visibleText;
1818
- state.lastText = visibleText;
1819
- state.phraseBuffer = visibleText;
1820
- }
1821
- // If the last ripple just expired and text is stable,
1822
- // start the cooldown from now for future changes.
1823
- if (justExpired) {
1824
- state.lastAnimTime = now;
1825
- }
1826
- // Fully stable — nothing to do
1827
- }
1828
- else if (justExpired || now - state.lastAnimTime >= MIN_RIPPLE_INTERVAL) {
1829
- // Spawn ONE fresh ripple immediately if the old one just expired
1830
- // (no overlap risk — previous ripple is fully gone) OR if cooled down.
1831
- state.lastText = visibleText;
1832
- state.displayedText = visibleText;
1833
- state.lastAnimTime = now;
1834
- state.phraseBuffer = visibleText;
1835
- if (this.mode === 'cascade') {
1836
- state.queue = buildQueue(oldText, visibleText);
1837
- state.startTime = now;
1838
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
1839
- }
1840
- else {
1841
- state.ripples.push(...spawnRippleForText(randomSentenceStart(visibleText), now, visibleText.length, undefined, true));
1842
- }
1843
- }
1844
- else {
1845
- // Not cooled down — track latest text but keep displayedText frozen
1846
- // so any residual scramble from previous frames stays visible.
1847
- state.lastText = visibleText;
1848
- // DO NOT update displayedText or phraseBuffer — prevents plain-text flash
1849
- }
1850
- }
1851
- }
1852
- }
1853
- else {
1854
- processLine(state, visibleText, now, this.mode, 'msg');
1855
- }
1856
- const hasActiveRipple = this.isLineAnimating(state, now);
1857
- // Always render visibleText — ripple wavefront scrambles whatever it hits,
1858
- // and new content outside the wavefront shows as plain. state.displayedText
1859
- // stays frozen for chunk-detection (shouldFlushPhrase), not for rendering.
1860
- const displayText = visibleText;
1861
- const content = applyScramble(displayText, state, now, this.mode, 'msg', () => this.poolRandomChar());
1862
- const isAnimating = this.isLineAnimating(state, now);
1863
- return { label: 'msg:', content, isAnimating };
1864
- }
1865
- // -----------------------------------------------------------------------
1866
- // STREAM mode: typewriter progressive reveal
1867
- // -----------------------------------------------------------------------
1868
- /**
1869
- * Stream msg: text with typewriter reveal.
1870
- *
1871
- * Tail-view semantics: only the last `budget` chars are visible. As text
1872
- * grows the window slides. We track `revealedCount` relative to the
1873
- * CURRENT visible text so that previously-visible resolved chars stay
1874
- * resolved and only newly-entered chars are scrambled.
1875
- */
1876
- streamMsg(id, fullText, now, isComplete, budget) {
1877
- if (isComplete) {
1878
- const record = this.streamState.get(id);
1879
- if (!record) {
1880
- const cleanText = stripAnsi(fullText);
1881
- return tailText(cleanText, budget);
1882
- }
1883
- }
1884
- const state = this.getStreamState(id, 'msg');
1885
- if (isComplete && !state.completed) {
1886
- state.completed = true;
1887
- }
1888
- // Reset if a previously-completed flow is now running again (new flow started)
1889
- if (!isComplete && state.completed) {
1890
- state.completed = false;
1891
- state.revealedCount = 0;
1892
- state.lastRevealTime = 0;
1893
- state.cursorChars = [];
1894
- state.fullText = '';
1895
- state.lastVisibleText = '';
1896
- }
1897
- // Strip ANSI for stable comparison
1898
- const cleanText = stripAnsi(fullText);
1899
- // Compute old and new visible windows (tail text)
1900
- const oldVisibleText = state.lastVisibleText || '';
1901
- const newVisibleText = tailText(cleanText, budget);
1902
- if (oldVisibleText) {
1903
- // Find how much of the old visible text is still at the start of
1904
- // the new visible text. Chars that slid out of view reduce the
1905
- // revealed count so the visible window doesn't flash to pure noise.
1906
- // Only trust the overlap if the new text continues from the old;
1907
- // otherwise it's a rewrite and we start from zero.
1908
- let overlapLen = 0;
1909
- if (state.fullText && cleanText.startsWith(state.fullText)) {
1910
- overlapLen = computeOverlapLen(oldVisibleText, newVisibleText);
1911
- }
1912
- else if (oldVisibleText && newVisibleText) {
1913
- // Non-extension (backtracking/rephrasing): preserve revealed count if visible window still overlaps significantly
1914
- const candidateOverlap = computeOverlapLen(oldVisibleText, newVisibleText);
1915
- const minVisibleLen = Math.min(oldVisibleText.length, newVisibleText.length);
1916
- if (candidateOverlap >= minVisibleLen * 0.5) {
1917
- overlapLen = candidateOverlap;
1918
- }
1919
- }
1920
- const charsSlidOut = oldVisibleText.length - overlapLen;
1921
- state.revealedCount = Math.max(0, state.revealedCount - charsSlidOut);
1922
- if (charsSlidOut > 0) {
1923
- // Reset scramble cursor when the visible window shifts so stale
1924
- // scramble chars don't linger at wrong positions.
1925
- state.cursorChars = [];
1926
- }
1927
- }
1928
- state.fullText = cleanText;
1929
- state.lastVisibleText = newVisibleText;
1930
- // Advance cursor
1931
- if (state.completed) {
1932
- state.revealedCount = newVisibleText.length;
1933
- }
1934
- else if (state.lastRevealTime > 0) {
1935
- const elapsed = Math.max(0, now - state.lastRevealTime);
1936
- const charsToReveal = Math.floor(elapsed / state.speed);
1937
- if (charsToReveal > 0) {
1938
- state.revealedCount = Math.min(state.revealedCount + charsToReveal, newVisibleText.length);
1939
- state.lastRevealTime += charsToReveal * state.speed;
1940
- }
1941
- }
1942
- else {
1943
- // First frame — start the clock
1944
- state.lastRevealTime = now;
1945
- }
1946
- // All revealed
1947
- if (state.revealedCount >= newVisibleText.length) {
1948
- return newVisibleText;
1949
- }
1950
- return renderStreamText(newVisibleText, state.revealedCount, state.scrambleWidth, state.cursorChars);
1951
- }
1952
- /**
1953
- * Stream act: text with typewriter reveal.
1954
- * When tool call text changes, reset the buffer and reveal new text.
1955
- * Budget controls truncation (truncateChars, shows beginning).
1956
- */
1957
- streamAct(id, fullText, now, isComplete, budget) {
1958
- if (isComplete) {
1959
- const record = this.streamState.get(id);
1960
- if (!record) {
1961
- const cleanText = stripAnsi(fullText);
1962
- return cleanText.length > budget ? cleanText.slice(0, budget) : cleanText;
1963
- }
1964
- }
1965
- const state = this.getStreamState(id, 'act');
1966
- if (isComplete && !state.completed) {
1967
- state.completed = true;
1968
- }
1969
- // Reset if a previously-completed flow is now running again (new flow started)
1970
- if (!isComplete && state.completed) {
1971
- state.completed = false;
1972
- state.revealedCount = 0;
1973
- state.lastRevealTime = 0;
1974
- state.cursorChars = [];
1975
- state.fullText = '';
1976
- }
1977
- // Strip ANSI for stable comparison (formatFlowToolCall adds color codes)
1978
- const cleanText = stripAnsi(fullText);
1979
- // Detect tool call change — reset only when the tool name (first word) changes.
1980
- // This avoids restarting the typewriter for minor arg changes of the same tool.
1981
- if (state.fullText && cleanText !== state.fullText) {
1982
- const oldTool = state.fullText.split(' ')[0];
1983
- const newTool = cleanText.split(' ')[0];
1984
- if (oldTool !== newTool) {
1985
- state.fullText = cleanText;
1986
- state.revealedCount = 0;
1987
- state.lastRevealTime = now;
1988
- state.cursorChars = [];
1989
- }
1990
- else {
1991
- state.fullText = cleanText;
1992
- }
1993
- }
1994
- else if (!state.fullText) {
1995
- state.fullText = cleanText;
1996
- }
1997
- // Advance cursor
1998
- if (state.completed) {
1999
- state.revealedCount = state.fullText.length;
2000
- }
2001
- else if (state.lastRevealTime > 0) {
2002
- const elapsed = Math.max(0, now - state.lastRevealTime);
2003
- const charsToReveal = Math.floor(elapsed / state.speed);
2004
- if (charsToReveal > 0) {
2005
- state.revealedCount = Math.min(state.revealedCount + charsToReveal, state.fullText.length);
2006
- state.lastRevealTime += charsToReveal * state.speed;
2007
- }
2008
- }
2009
- else {
2010
- state.lastRevealTime = now;
2011
- }
2012
- // All revealed
2013
- if (state.revealedCount >= state.fullText.length) {
2014
- return state.fullText.length > budget ? state.fullText.slice(0, budget) : state.fullText;
2015
- }
2016
- // Compute visible window (truncated, shows beginning for tool calls)
2017
- const visibleText = state.fullText.length > budget ? state.fullText.slice(0, budget) : state.fullText;
2018
- const visibleRevealed = Math.min(state.revealedCount, visibleText.length);
2019
- if (visibleRevealed >= visibleText.length) {
2020
- return visibleText;
2021
- }
2022
- return renderStreamText(visibleText, visibleRevealed, state.scrambleWidth, state.cursorChars);
2023
- }
2024
- // -----------------------------------------------------------------------
2025
- // Value flash helpers (shared by TPS, act KPI, msg KPI)
2026
- // -----------------------------------------------------------------------
2027
- _setupValueFlash(state, value, now) {
2028
- if (this.mode === 'cascade') {
2029
- state.queue = buildQueue(state.prev, value, CASCADE_FLASH_MAX_START, CASCADE_FLASH_MAX_LENGTH);
2030
- state.startTime = now;
2031
- state.queueMaxEnd = state.queue.reduce((max, item) => Math.max(max, item.end), 0);
2032
- }
2033
- else {
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 = [];
2040
- }
2041
- }
2042
- _renderValueFlash(state, value, now) {
2043
- if (this.mode === 'cascade') {
2044
- if (state.queue.length) {
2045
- const frame = Math.max(0, Math.floor((now - state.startTime) / CASCADE_FRAME_MS));
2046
- if (isCascadeComplete(state.queue, frame, state.queueMaxEnd)) {
2047
- state.queue = [];
2048
- state.startTime = now;
2049
- return value;
2050
- }
2051
- return computeCascadeFrame(state.queue, frame, () => this.poolRandomChar());
2052
- }
2053
- return value;
2054
- }
2055
- else {
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());
2064
- }
2065
- state.prev = value;
2066
- return value;
2067
- }
2068
- }
2069
- _updateValueKpi(map, id, value, now, isComplete, staticLine) {
2070
- if (isComplete) {
2071
- const s = map.get(id);
2072
- if (!s) {
2073
- const newState = createValueFlashState();
2074
- newState.completed = true;
2075
- map.set(id, newState);
2076
- return newState;
2077
- }
2078
- s.completed = true;
2079
- s.queue = [];
2080
- s.ripples = [];
2081
- return s;
2082
- }
2083
- let state = map.get(id);
2084
- const isFirstCall = !state;
2085
- if (!state) {
2086
- state = createValueFlashState();
2087
- state.prev = value;
2088
- state.lastValueChangeTime = now;
2089
- map.set(id, state);
2090
- }
2091
- // Reset if a previously-completed flow is now running again
2092
- if (!isComplete && state.completed) {
2093
- state.completed = false;
2094
- state.prev = '';
2095
- state.queue = [];
2096
- state.ripples = [];
2097
- state.startTime = 0;
2098
- state.lastRippleEndTime = 0;
2099
- state.lastFlashTime = 0;
2100
- state.glitchQueue = [];
2101
- state.glitchFrame = 0;
2102
- }
2103
- if (state.completed)
2104
- return state;
2105
- const cooldownElapsed = now - state.lastFlashTime >= TPS_FLASH_COOLDOWN_MS;
2106
- if (state.prev !== value) {
2107
- let shouldFlash = staticLine ? state.startTime === 0 : true;
2108
- state.lastValueChangeTime = now;
2109
- if (shouldFlash && cooldownElapsed) {
2110
- this._setupValueFlash(state, value, now);
2111
- state.lastFlashTime = now;
2112
- }
2113
- else if (this.mode === 'cascade') {
2114
- state.queue = [];
2115
- }
2116
- else {
2117
- state.glitchQueue = [];
2118
- }
2119
- state.prev = value;
2120
- }
2121
- if (isFirstCall && staticLine && state.startTime === 0 && cooldownElapsed) {
2122
- this._setupValueFlash(state, value, now);
2123
- state.lastFlashTime = now;
2124
- }
2125
- return state;
2126
- }
2127
- // -----------------------------------------------------------------------
2128
- // TPS flash (cascade/ripple modes only)
2129
- // -----------------------------------------------------------------------
2130
- updateTps(id, tpsText, now, isComplete = false, staticLine = false) {
2131
- if (!tpsText || tpsText.trim() === '-')
2132
- return tpsText;
2133
- if (isComplete) {
2134
- const s = this.tpsState.get(id);
2135
- if (!s)
2136
- return tpsText;
2137
- }
2138
- let state = this.tpsState.get(id);
2139
- const isFirstCall = !state;
2140
- if (!state) {
2141
- state = createValueFlashState();
2142
- state.prev = tpsText;
2143
- state.lastValueChangeTime = now;
2144
- this.tpsState.set(id, state);
2145
- }
2146
- // Reset if a previously-completed flow is now running again (new flow started)
2147
- if (!isComplete && state.completed) {
2148
- state.completed = false;
2149
- state.prev = '';
2150
- state.queue = [];
2151
- state.ripples = [];
2152
- state.startTime = 0;
2153
- state.lastRippleEndTime = 0;
2154
- state.lastFlashTime = 0;
2155
- }
2156
- if (isComplete) {
2157
- state.completed = true;
2158
- state.queue = [];
2159
- state.ripples = [];
2160
- }
2161
- if (state.completed)
2162
- return tpsText;
2163
- const cooldownElapsed = now - state.lastFlashTime >= TPS_FLASH_COOLDOWN_MS;
2164
- if (state.prev !== tpsText) {
2165
- // Hysteresis: only flash on significant change or after settle time
2166
- // Static line: only allow flash on the very first value change
2167
- let shouldFlash = staticLine ? state.startTime === 0 : true;
2168
- const prevVal = parseFloat(state.prev);
2169
- const newVal = parseFloat(tpsText);
2170
- if (!isNaN(prevVal) && !isNaN(newVal) && prevVal !== 0) {
2171
- const deltaPct = Math.abs(newVal - prevVal) / prevVal;
2172
- const timeSinceLastChange = state.lastValueChangeTime > 0 ? now - state.lastValueChangeTime : 0;
2173
- shouldFlash = deltaPct > TPS_HYSTERESIS_PCT || timeSinceLastChange > TPS_HYSTERESIS_MS;
2174
- }
2175
- state.lastValueChangeTime = now;
2176
- if (shouldFlash && cooldownElapsed) {
2177
- this._setupValueFlash(state, tpsText, now);
2178
- state.lastFlashTime = now;
2179
- }
2180
- else if (this.mode === 'cascade') {
2181
- state.queue = []; // suppress old cascade when new value arrives without flash
2182
- }
2183
- state.prev = tpsText;
2184
- }
2185
- if (isFirstCall && staticLine && state.startTime === 0 && cooldownElapsed) {
2186
- // Static line: trigger initial flash on first value even though prev was set
2187
- this._setupValueFlash(state, tpsText, now);
2188
- state.lastFlashTime = now;
2189
- }
2190
- return this._renderValueFlash(state, tpsText, now);
2191
- }
2192
- updateActKpi(id, value, now, isComplete = false, staticLine = false) {
2193
- const state = this._updateValueKpi(this.actKpiState, id, value, now, isComplete, staticLine);
2194
- return this._renderValueFlash(state, value, now);
2195
- }
2196
- updateMsgKpi(id, value, now, isComplete = false, staticLine = false) {
2197
- const state = this._updateValueKpi(this.msgKpiState, id, value, now, isComplete, staticLine);
2198
- return this._renderValueFlash(state, value, now);
2199
- }
2200
- // -----------------------------------------------------------------------
2201
- // Animation status helpers
2202
- // -----------------------------------------------------------------------
2203
- isLineAnimating(state, now) {
2204
- if (state.completed)
2205
- return false;
2206
- if (this.mode === 'cascade') {
2207
- if (!state.queue.length)
2208
- return false;
2209
- const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2210
- return !isCascadeComplete(state.queue, frame, state.queueMaxEnd);
2211
- }
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
- }
2217
- return state.ripples.some((rp) => rp.time + rp.dur + (rp.contentChange ? ECHO_AFTERGLOW_MS : AFTERGLOW_MS) > now);
2218
- }
2219
- }
2220
- isStreamAnimating(state) {
2221
- if (state.completed)
2222
- return false;
2223
- const visibleText = state.lastVisibleText || state.fullText;
2224
- return state.revealedCount < visibleText.length;
2225
- }
2226
- hasActiveAnimations(id, now) {
2227
- // Stream mode
2228
- if (this.mode === 'stream') {
2229
- const streamRecord = this.streamState.get(id);
2230
- if (streamRecord) {
2231
- if (this.isStreamAnimating(streamRecord.msg))
2232
- return true;
2233
- if (this.isStreamAnimating(streamRecord.act))
2234
- return true;
2235
- }
2236
- return false;
2237
- }
2238
- // Cascade/ripple/illuminate
2239
- const record = this.cache.get(id);
2240
- if (record) {
2241
- for (const key of ['aim', 'act', 'msg']) {
2242
- if (this.isLineAnimating(record[key], now))
2243
- return true;
2244
- }
2245
- }
2246
- // Generic cache entries for this id
2247
- const prefix = `${id}#`;
2248
- for (const [key, state] of this.genericCache) {
2249
- if (key.startsWith(prefix) && this.isLineAnimating(state, now))
2250
- return true;
2251
- }
2252
- return false;
2253
- }
2254
- hasAnyActiveAnimations(now) {
2255
- // Stream mode
2256
- if (this.mode === 'stream') {
2257
- for (const record of this.streamState.values()) {
2258
- if (this.isStreamAnimating(record.msg))
2259
- return true;
2260
- if (this.isStreamAnimating(record.act))
2261
- return true;
2262
- }
2263
- return false;
2264
- }
2265
- // Cascade/ripple/illuminate
2266
- for (const record of this.cache.values()) {
2267
- for (const key of ['aim', 'act', 'msg']) {
2268
- if (this.isLineAnimating(record[key], now))
2269
- return true;
2270
- }
2271
- }
2272
- for (const state of this.tpsState.values()) {
2273
- if (state.completed)
2274
- continue;
2275
- if (this.mode === 'cascade') {
2276
- if (state.queue.length) {
2277
- const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2278
- if (!isCascadeComplete(state.queue, frame, state.queueMaxEnd))
2279
- return true;
2280
- }
2281
- }
2282
- else {
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
- }
2288
- }
2289
- }
2290
- for (const state of this.actKpiState.values()) {
2291
- if (state.completed)
2292
- continue;
2293
- if (this.mode === 'cascade') {
2294
- if (state.queue.length) {
2295
- const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2296
- if (!isCascadeComplete(state.queue, frame, state.queueMaxEnd))
2297
- return true;
2298
- }
2299
- }
2300
- else {
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
- }
2306
- }
2307
- }
2308
- for (const state of this.msgKpiState.values()) {
2309
- if (state.completed)
2310
- continue;
2311
- if (this.mode === 'cascade') {
2312
- if (state.queue.length) {
2313
- const frame = Math.floor((now - state.startTime) / CASCADE_FRAME_MS);
2314
- if (!isCascadeComplete(state.queue, frame, state.queueMaxEnd))
2315
- return true;
2316
- }
2317
- }
2318
- else {
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
- }
2324
- }
2325
- }
2326
- for (const state of this.genericCache.values()) {
2327
- if (this.isLineAnimating(state, now))
2328
- return true;
2329
- }
2330
- return false;
2331
- }
2332
- clear() {
2333
- this.cache.clear();
2334
- this.tpsState.clear();
2335
- this.actKpiState.clear();
2336
- this.msgKpiState.clear();
2337
- this.streamState.clear();
2338
- this.genericCache.clear();
2339
- }
2340
- sweepCompletedEntries() {
2341
- if (this.cache.size <= MAX_FLOW_ENTRIES && this.streamState.size <= MAX_FLOW_ENTRIES && this.tpsState.size <= MAX_FLOW_ENTRIES && this.actKpiState.size <= MAX_FLOW_ENTRIES && this.msgKpiState.size <= MAX_FLOW_ENTRIES && this.genericCache.size <= MAX_FLOW_ENTRIES * 2) {
2342
- return;
2343
- }
2344
- for (const [id, record] of this.cache) {
2345
- if (record.aim.completed && record.act.completed && record.msg.completed) {
2346
- this.cache.delete(id);
2347
- }
2348
- }
2349
- for (const [id, state] of this.streamState) {
2350
- if (state.msg.completed && state.act.completed) {
2351
- this.streamState.delete(id);
2352
- }
2353
- }
2354
- for (const [id, state] of this.tpsState) {
2355
- if (state.completed) {
2356
- this.tpsState.delete(id);
2357
- }
2358
- }
2359
- for (const [id, state] of this.actKpiState) {
2360
- if (state.completed) {
2361
- this.actKpiState.delete(id);
2362
- }
2363
- }
2364
- for (const [id, state] of this.msgKpiState) {
2365
- if (state.completed) {
2366
- this.msgKpiState.delete(id);
2367
- }
2368
- }
2369
- for (const [key, state] of this.genericCache) {
2370
- if (state.completed) {
2371
- this.genericCache.delete(key);
2372
- }
2373
- }
2374
- // Age-based eviction for orphaned never-completed generic entries
2375
- const now = Date.now();
2376
- for (const [key, state] of this.genericCache) {
2377
- if (now - state.lastAccessTime > MAX_CACHE_AGE_MS) {
2378
- this.genericCache.delete(key);
2379
- }
2380
- }
2381
- }
2382
- completeFlow(id) {
2383
- const record = this.cache.get(id);
2384
- if (record) {
2385
- for (const key of ['aim', 'act', 'msg']) {
2386
- record[key].completed = true;
2387
- record[key].queue = [];
2388
- record[key].ripples = [];
2389
- record[key].phraseBuffer = '';
2390
- record[key].displayedText = '';
2391
- record[key].pendingText = '';
2392
- record[key].lastFlushTime = 0;
2393
- record[key].lastRippleEndTime = 0;
2394
- record[key].glitchQueue = [];
2395
- record[key].glitchFrame = 0;
2396
- }
2397
- }
2398
- const tpsState = this.tpsState.get(id);
2399
- if (tpsState) {
2400
- tpsState.completed = true;
2401
- tpsState.queue = [];
2402
- tpsState.ripples = [];
2403
- tpsState.lastRippleEndTime = 0;
2404
- tpsState.glitchQueue = [];
2405
- tpsState.glitchFrame = 0;
2406
- }
2407
- const actKpiState = this.actKpiState.get(id);
2408
- if (actKpiState) {
2409
- actKpiState.completed = true;
2410
- actKpiState.queue = [];
2411
- actKpiState.ripples = [];
2412
- actKpiState.glitchQueue = [];
2413
- actKpiState.glitchFrame = 0;
2414
- }
2415
- const msgKpiState = this.msgKpiState.get(id);
2416
- if (msgKpiState) {
2417
- msgKpiState.completed = true;
2418
- msgKpiState.queue = [];
2419
- msgKpiState.ripples = [];
2420
- msgKpiState.glitchQueue = [];
2421
- msgKpiState.glitchFrame = 0;
2422
- }
2423
- const streamRecord = this.streamState.get(id);
2424
- if (streamRecord) {
2425
- streamRecord.msg.completed = true;
2426
- streamRecord.msg.revealedCount = streamRecord.msg.lastVisibleText?.length ?? streamRecord.msg.fullText.length;
2427
- streamRecord.act.completed = true;
2428
- streamRecord.act.revealedCount = streamRecord.act.fullText.length;
2429
- }
2430
- // Mark generic entries for this id as completed
2431
- const prefix = `${id}#`;
2432
- for (const [key, state] of this.genericCache) {
2433
- if (key.startsWith(prefix)) {
2434
- state.completed = true;
2435
- state.queue = [];
2436
- state.ripples = [];
2437
- state.glitchQueue = [];
2438
- state.glitchFrame = 0;
2439
- state.lastRippleEndTime = 0;
2440
- }
2441
- }
2442
- this.sweepCompletedEntries();
2443
- }
2444
- /** Legacy aliases */
2445
- hasActiveRipples(id, now) {
2446
- return this.hasActiveAnimations(id, now);
2447
- }
2448
- hasAnyActiveRipples(now) {
2449
- return this.hasAnyActiveAnimations(now);
2450
- }
2451
- }
2452
- /**
2453
- * Shared animation timer — wired by any renderer that uses scrambleManager.
2454
- * Uses chained setTimeout (not setInterval) to avoid TUI ghost frames.
2455
- */
2456
- export function runScrambleTimer(args) {
2457
- if (args?.invalidate && args?.state) {
2458
- const s = args.state.__scramble = args.state.__scramble || {};
2459
- const now = Date.now();
2460
- const hasActive = scrambleManager.hasAnyActiveAnimations(now);
2461
- if (hasActive) {
2462
- if (!s.animTimer) {
2463
- const interval = CASCADE_FRAME_MS;
2464
- s.animTimer = setTimeout(() => {
2465
- s.animTimer = undefined;
2466
- args.invalidate();
2467
- }, interval);
2468
- }
2469
- }
2470
- else if (s.animTimer) {
2471
- clearTimeout(s.animTimer);
2472
- s.animTimer = undefined;
2473
- }
2474
- }
2475
- }
2476
- /** Module-level singleton for use across render calls. */
2477
- export const scrambleManager = new ScrambleStateManager();
2478
- //# sourceMappingURL=scramble.js.map