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