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