@makefinks/daemon 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/ai/daemon-ai.ts +8 -35
  3. package/src/ai/message-utils.ts +26 -0
  4. package/src/ai/tools/subagents.ts +8 -7
  5. package/src/app/App.tsx +3 -0
  6. package/src/app/components/AvatarLayer.tsx +96 -24
  7. package/src/app/components/ConversationPane.tsx +9 -8
  8. package/src/avatar/DaemonAvatarRenderable.ts +26 -3
  9. package/src/avatar/daemon-avatar-rig.ts +10 -1159
  10. package/src/avatar/rig/core/rig-engine.ts +202 -0
  11. package/src/avatar/rig/core/rig-types.ts +17 -0
  12. package/src/avatar/rig/scene/create-scene-elements.ts +298 -0
  13. package/src/avatar/rig/state/rig-state.ts +193 -0
  14. package/src/avatar/rig/theme/rig-theme.ts +31 -0
  15. package/src/avatar/rig/tools/rig-tools.ts +8 -0
  16. package/src/avatar/rig/update/update-core.ts +32 -0
  17. package/src/avatar/rig/update/update-eye.ts +31 -0
  18. package/src/avatar/rig/update/update-fragments.ts +46 -0
  19. package/src/avatar/rig/update/update-glitch.ts +64 -0
  20. package/src/avatar/rig/update/update-idle.ts +95 -0
  21. package/src/avatar/rig/update/update-intensity.ts +16 -0
  22. package/src/avatar/rig/update/update-main-anchor.ts +20 -0
  23. package/src/avatar/rig/update/update-particles.ts +49 -0
  24. package/src/avatar/rig/update/update-rings.ts +35 -0
  25. package/src/avatar/rig/update/update-sigils.ts +26 -0
  26. package/src/avatar/rig/update/update-spawn.ts +83 -0
  27. package/src/avatar/rig/utils/math.ts +17 -0
  28. package/src/components/ContentBlockView.tsx +6 -1
  29. package/src/components/ToolCallView.tsx +9 -12
  30. package/src/components/tool-layouts/components.tsx +4 -3
  31. package/src/components/tool-layouts/layouts/subagent.tsx +140 -16
  32. package/src/hooks/daemon-event-handlers.ts +3 -3
  33. package/src/hooks/use-app-controller.ts +51 -1
  34. package/src/hooks/use-app-display-state.ts +1 -1
  35. package/src/hooks/use-glitchy-banner.ts +175 -0
  36. package/src/hooks/use-reasoning-animation.ts +1 -1
  37. package/src/state/daemon-state.ts +3 -3
  38. package/src/ui/reasoning-ticker.tsx +4 -1
  39. package/src/ui/startup.ts +5 -0
  40. package/src/utils/debug-logger.ts +34 -23
@@ -0,0 +1,31 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+ import { lerpColor } from "../utils/math";
4
+
5
+ export function updateThemeColors(elements: SceneElements, state: RigState, dt: number): void {
6
+ const { theme, typing, tool } = state;
7
+
8
+ const t = dt * 4;
9
+ theme.current.primary = lerpColor(theme.current.primary, theme.target.primary, t);
10
+ theme.current.glow = lerpColor(theme.current.glow, theme.target.glow, t);
11
+ theme.current.eye = lerpColor(theme.current.eye, theme.target.eye, t);
12
+
13
+ let displayPrimary = theme.current.primary;
14
+ let displayEye = theme.current.eye;
15
+
16
+ if (typing.pulse > 0.01) {
17
+ const flashStrength = Math.pow(typing.pulse, 1.5) * 0.5;
18
+ displayPrimary = lerpColor(displayPrimary, 0xffffff, flashStrength);
19
+ displayEye = lerpColor(displayEye, 0xff8888, flashStrength * 0.3);
20
+ }
21
+
22
+ elements.glowMat.color.setHex(displayPrimary);
23
+ if (tool.flashTimer <= 0) {
24
+ elements.eyeMat.color.setHex(displayEye);
25
+ elements.pupilMat.color.setHex(displayEye);
26
+ }
27
+ elements.pointLight.color.setHex(theme.current.glow);
28
+
29
+ elements.rings.forEach((ring) => ring.material.color.setHex(displayPrimary));
30
+ elements.fragments.forEach((frag) => frag.material.color.setHex(displayPrimary));
31
+ }
@@ -0,0 +1,8 @@
1
+ export type ToolCategory = "web" | "file" | "bash" | "subagent";
2
+
3
+ export const TOOL_CATEGORY_COLORS: Record<ToolCategory, number> = {
4
+ web: 0x22d3ee,
5
+ file: 0x4ade80,
6
+ bash: 0xfbbf24,
7
+ subagent: 0xa78bfa,
8
+ };
@@ -0,0 +1,32 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+ import { clamp01 } from "../utils/math";
4
+
5
+ export function updateCore(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
6
+ const { phase, audio, reasoning } = state;
7
+
8
+ const coreSpeed = 0.1 + intensity * 0.9;
9
+ elements.glowMesh.rotation.y += dt * coreSpeed;
10
+ elements.glowMesh.rotation.x += dt * coreSpeed * 0.6;
11
+
12
+ const glowScale = 1 + intensity * 0.25;
13
+ elements.glowMesh.scale.setScalar(glowScale);
14
+ elements.glowMat.opacity = clamp01(0.4 + intensity * 0.4 + audio.current * 0.25);
15
+ const normalCorePulseSpeed = 1 + intensity * 4;
16
+ const reasoningCorePulseSpeed = 0.4;
17
+ const corePulseSpeed =
18
+ normalCorePulseSpeed * (1 - reasoning.blend) + reasoningCorePulseSpeed * reasoning.blend;
19
+ phase.corePulse += dt * corePulseSpeed;
20
+
21
+ const normalCorePulseAmount = 0.01 + intensity * 0.15;
22
+ const reasoningCorePulseAmount = 0.08;
23
+ const corePulseAmount =
24
+ normalCorePulseAmount * (1 - reasoning.blend) + reasoningCorePulseAmount * reasoning.blend;
25
+ const corePulse = 1 + Math.sin(phase.corePulse) * corePulseAmount;
26
+
27
+ elements.coreGroup.scale.setScalar(corePulse * (1 + audio.current * 0.18));
28
+
29
+ const squashStretch = 1 + audio.current * 0.15;
30
+ elements.coreMesh.scale.set(1, squashStretch, 1);
31
+ elements.glowMesh.scale.y *= squashStretch;
32
+ }
@@ -0,0 +1,31 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+ import { clamp01, lerpColor } from "../utils/math";
4
+
5
+ export function updateEye(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
6
+ const { phase, audio, typing, reasoning, tool, theme } = state;
7
+
8
+ const eyeSpeed = 1.5 + intensity * 4;
9
+ phase.eyePulse += dt * eyeSpeed;
10
+ const eyePulseAmount = 0.1 + intensity * 0.3 + typing.pulse * 0.1;
11
+ const eyePulse = 0.9 + Math.sin(phase.eyePulse) * eyePulseAmount;
12
+ elements.eye.scale.setScalar(eyePulse * (1 + audio.current * 0.1));
13
+
14
+ const pupilSpeed = 2 + intensity * 5;
15
+ phase.pupilPulse += dt * pupilSpeed;
16
+ const pupilPulseAmount = 0.15 + intensity * 0.35 + typing.pulse * 0.15;
17
+ const normalPupilBase = 0.8 + Math.sin(phase.pupilPulse) * pupilPulseAmount;
18
+ const reasoningPupilDilation = 1.4;
19
+ const pupilBase = normalPupilBase * (1 - reasoning.blend) + reasoningPupilDilation * reasoning.blend;
20
+ elements.pupil.scale.setScalar(pupilBase * (1 + audio.current * 0.08));
21
+
22
+ if (tool.flashTimer > 0) {
23
+ tool.flashTimer -= dt;
24
+ const flashIntensity = clamp01(tool.flashTimer / 0.15);
25
+ elements.eyeMat.color.setHex(lerpColor(theme.current.eye, tool.flashColor, flashIntensity));
26
+ elements.pupilMat.color.setHex(lerpColor(theme.current.eye, tool.flashColor, flashIntensity));
27
+ }
28
+
29
+ elements.eyeMat.opacity = clamp01(0.85 + intensity * 0.15 + audio.current * 0.15);
30
+ elements.pupilMat.opacity = clamp01(0.9 + intensity * 0.1 + audio.current * 0.1);
31
+ }
@@ -0,0 +1,46 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+ import { clamp01 } from "../utils/math";
4
+
5
+ export function updateFragments(
6
+ elements: SceneElements,
7
+ state: RigState,
8
+ dt: number,
9
+ intensity: number
10
+ ): void {
11
+ const { audio, tool, reasoning, intensity: intensityState } = state;
12
+
13
+ tool.fragmentScatterBoost += (0 - tool.fragmentScatterBoost) * dt * 6;
14
+ tool.settleTimer = Math.max(0, tool.settleTimer - dt);
15
+
16
+ const settleContraction = tool.settleTimer > 0 ? Math.sin(tool.settleTimer * 20) * 0.08 : 0;
17
+ const reasoningContraction = reasoning.blend * 0.25;
18
+ const fragmentScale =
19
+ 0.5 + intensity * 0.6 + tool.fragmentScatterBoost - settleContraction - reasoningContraction;
20
+ elements.fragmentGroup.scale.setScalar(fragmentScale);
21
+
22
+ elements.fragments.forEach((frag) => {
23
+ const reasoningOrbitSlowdown = 1 - reasoning.blend * 0.6;
24
+ const audioOrbitBoost = audio.current * 0.4;
25
+ const orbitSpeed =
26
+ frag.orbitSpeed * (0.4 + intensity * 1.2 + audioOrbitBoost) * reasoningOrbitSlowdown +
27
+ intensityState.spinBoost * 0.5;
28
+ frag.orbitAngle += dt * orbitSpeed;
29
+
30
+ const dynamicRadius = frag.orbitRadius;
31
+ const bobAmount = 0.08 + intensity * 0.2;
32
+ const bobSpeed = frag.bobSpeed * (0.5 + intensity * 1.0);
33
+ frag.bobPhase += dt * bobSpeed;
34
+
35
+ const bob = Math.sin(frag.bobPhase) * bobAmount;
36
+ frag.mesh.position.x = Math.cos(frag.orbitAngle) * dynamicRadius;
37
+ frag.mesh.position.z = Math.sin(frag.orbitAngle) * dynamicRadius;
38
+ frag.mesh.position.y += (bob - frag.mesh.position.y) * dt * 3;
39
+
40
+ const tumbleSpeed = 0.1 + intensity * 0.5;
41
+ frag.mesh.rotation.x += dt * tumbleSpeed;
42
+ frag.mesh.rotation.y += dt * tumbleSpeed * 1.5;
43
+
44
+ frag.material.opacity = clamp01(0.45 + intensity * 0.4 + audio.current * 0.22);
45
+ });
46
+ }
@@ -0,0 +1,64 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+
4
+ export function updateGlitchBehavior(
5
+ elements: SceneElements,
6
+ state: RigState,
7
+ dt: number,
8
+ intensity: number,
9
+ allowGlitch: boolean
10
+ ): void {
11
+ const { glitch } = state;
12
+
13
+ if (!allowGlitch) {
14
+ glitch.timer = 0;
15
+ if (glitch.isActive) {
16
+ glitch.isActive = false;
17
+ elements.coreGroup.position.set(0, 0, 0);
18
+ elements.fragmentGroup.scale.set(1, 1, 1);
19
+ }
20
+ return;
21
+ }
22
+
23
+ glitch.timer += dt;
24
+
25
+ const intensityFactor = Math.max(0.1, intensity);
26
+ const baseInterval = 3.0 / intensityFactor;
27
+ const randomFactor = 0.5 + Math.random();
28
+ const glitchInterval = baseInterval * randomFactor;
29
+
30
+ if (!glitch.isActive && glitch.timer > glitchInterval) {
31
+ glitch.isActive = true;
32
+ glitch.duration = 0.05 + Math.random() * 0.1 + intensity * 0.05;
33
+ glitch.timer = 0;
34
+ }
35
+
36
+ if (glitch.isActive) {
37
+ glitch.duration -= dt;
38
+
39
+ const displaceMult = intensity * 0.5;
40
+ elements.coreGroup.position.set(
41
+ (Math.random() - 0.5) * 0.05 * displaceMult,
42
+ (Math.random() - 0.5) * 0.05 * displaceMult,
43
+ (Math.random() - 0.5) * 0.03 * displaceMult
44
+ );
45
+
46
+ const scatterAmount = 1.0 + (Math.random() - 0.5) * intensity * 0.15;
47
+ elements.fragmentGroup.scale.setScalar(scatterAmount);
48
+
49
+ elements.rings.forEach((ring, i) => {
50
+ const baseOpacity = 0.5 + i * 0.15;
51
+ const flickerRange = intensity * 0.2;
52
+ ring.material.opacity = baseOpacity * (1 - flickerRange + Math.random() * flickerRange * 2);
53
+ });
54
+
55
+ if (glitch.duration <= 0) {
56
+ glitch.isActive = false;
57
+ elements.coreGroup.position.set(0, 0, 0);
58
+ elements.fragmentGroup.scale.set(1, 1, 1);
59
+ elements.rings.forEach((ring, i) => {
60
+ ring.material.opacity = 0.5 + i * 0.15;
61
+ });
62
+ }
63
+ }
64
+ }
@@ -0,0 +1,95 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+
4
+ export function updateIdleAmbience(
5
+ elements: SceneElements,
6
+ state: RigState,
7
+ dt: number,
8
+ isIdle: boolean
9
+ ): void {
10
+ const { idleMicroGlitch, eyeDrift, particlePulse, coreDrift, typing } = state;
11
+
12
+ if (isIdle) {
13
+ idleMicroGlitch.timer += dt;
14
+ if (!idleMicroGlitch.active && idleMicroGlitch.timer > idleMicroGlitch.cooldown) {
15
+ idleMicroGlitch.active = true;
16
+ idleMicroGlitch.duration = 0.08 + Math.random() * 0.12;
17
+ idleMicroGlitch.timer = 0;
18
+ idleMicroGlitch.cooldown = 3 + Math.random() * 5;
19
+ }
20
+ if (idleMicroGlitch.active) {
21
+ idleMicroGlitch.duration -= dt;
22
+ const jitterAmount = 0.06;
23
+ elements.coreGroup.position.x += (Math.random() - 0.5) * jitterAmount;
24
+ elements.coreGroup.position.y += (Math.random() - 0.5) * jitterAmount;
25
+ if (idleMicroGlitch.duration <= 0) {
26
+ idleMicroGlitch.active = false;
27
+ elements.coreGroup.position.x = 0;
28
+ elements.coreGroup.position.y = 0;
29
+ }
30
+ }
31
+ } else {
32
+ idleMicroGlitch.timer = 0;
33
+ idleMicroGlitch.active = false;
34
+ }
35
+
36
+ if (typing.active) {
37
+ typing.eyeScanTimer += dt;
38
+ if (typing.eyeScanTimer > typing.eyeScanInterval) {
39
+ typing.eyeScanTimer = 0;
40
+ const scanWidth = 0.2;
41
+ eyeDrift.targetX = (Math.random() - 0.5) * scanWidth;
42
+ eyeDrift.targetY = (Math.random() - 0.5) * 0.05;
43
+ typing.eyeScanInterval = 0.3 + Math.random() * 0.8;
44
+ }
45
+ const trackSpeed = 5;
46
+ eyeDrift.x += (eyeDrift.targetX - eyeDrift.x) * dt * trackSpeed;
47
+ eyeDrift.y += (eyeDrift.targetY - eyeDrift.y) * dt * trackSpeed;
48
+ } else if (isIdle) {
49
+ eyeDrift.timer += dt;
50
+ if (eyeDrift.timer > eyeDrift.interval) {
51
+ eyeDrift.timer = 0;
52
+ const isFast = Math.random() > 0.4;
53
+ eyeDrift.interval = isFast ? 0.15 + Math.random() * 0.25 : 2.0 + Math.random() * 3.0;
54
+ eyeDrift.targetX = (Math.random() - 0.5) * 0.25;
55
+ eyeDrift.targetY = (Math.random() - 0.5) * 0.15;
56
+ }
57
+ const interpSpeed = eyeDrift.interval < 0.5 ? 30 : 1.5;
58
+ eyeDrift.x += (eyeDrift.targetX - eyeDrift.x) * dt * interpSpeed;
59
+ eyeDrift.y += (eyeDrift.targetY - eyeDrift.y) * dt * interpSpeed;
60
+ } else {
61
+ eyeDrift.x += (0 - eyeDrift.x) * dt * 4;
62
+ eyeDrift.y += (0 - eyeDrift.y) * dt * 4;
63
+ }
64
+
65
+ elements.eye.position.x = eyeDrift.x;
66
+ elements.eye.position.y = eyeDrift.y;
67
+ elements.pupil.position.x = eyeDrift.x;
68
+ elements.pupil.position.y = eyeDrift.y;
69
+
70
+ if (isIdle) {
71
+ particlePulse.timer += dt;
72
+ if (particlePulse.timer > particlePulse.interval) {
73
+ particlePulse.timer = 0;
74
+ particlePulse.interval = 1 + Math.random() * 2;
75
+ particlePulse.brightness = 1.0;
76
+ }
77
+ }
78
+ particlePulse.brightness = Math.max(0, particlePulse.brightness - dt * 1.5);
79
+
80
+ const coreDriftSpeed = 0.4;
81
+ const coreDriftAmount = 0.08;
82
+ coreDrift.phaseX += dt * coreDriftSpeed;
83
+ coreDrift.phaseY += dt * coreDriftSpeed * 0.7;
84
+ coreDrift.phaseZ += dt * coreDriftSpeed * 0.5;
85
+
86
+ const targetDriftX = Math.sin(coreDrift.phaseX) * coreDriftAmount;
87
+ const targetDriftY = Math.sin(coreDrift.phaseY) * coreDriftAmount;
88
+ const targetDriftZ = Math.sin(coreDrift.phaseZ) * coreDriftAmount * 0.5;
89
+
90
+ coreDrift.x += (targetDriftX - coreDrift.x) * dt * 2;
91
+ coreDrift.y += (targetDriftY - coreDrift.y) * dt * 2;
92
+ coreDrift.z += (targetDriftZ - coreDrift.z) * dt * 2;
93
+
94
+ elements.mainAnchor.position.set(coreDrift.x, coreDrift.y, coreDrift.z);
95
+ }
@@ -0,0 +1,16 @@
1
+ import type { RigState } from "../state/rig-state";
2
+
3
+ export function updateIntensityAndAudio(state: RigState, dt: number): number {
4
+ const { intensity, audio, typing, reasoning } = state;
5
+
6
+ typing.pulse = Math.max(0, typing.pulse - dt * 5);
7
+
8
+ const intensityRate = intensity.target > intensity.current ? 10 : 8;
9
+ intensity.current += (intensity.target - intensity.current) * dt * intensityRate;
10
+
11
+ intensity.spinBoost += (0 - intensity.spinBoost) * dt * 2.5;
12
+ audio.current += (audio.target - audio.current) * dt * 25;
13
+ reasoning.blend += ((reasoning.active ? 1 : 0) - reasoning.blend) * dt * 6;
14
+
15
+ return intensity.current;
16
+ }
@@ -0,0 +1,20 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+
4
+ export function updateMainAnchor(
5
+ elements: SceneElements,
6
+ state: RigState,
7
+ dt: number,
8
+ intensity: number
9
+ ): void {
10
+ const { phase, audio } = state;
11
+
12
+ const driftSpeed = 0.08 + intensity * 0.3;
13
+ phase.drift += dt * driftSpeed;
14
+ const driftAmount = 0.03 + intensity * 0.12;
15
+
16
+ elements.mainAnchor.rotation.y = Math.sin(phase.drift) * driftAmount;
17
+ elements.mainAnchor.rotation.x = Math.sin(phase.drift * 0.7) * driftAmount * 0.5;
18
+ elements.mainAnchor.rotation.z = Math.sin(phase.drift * 0.5) * intensity * 0.08;
19
+ elements.mainAnchor.scale.setScalar(1 + audio.current * 0.12);
20
+ }
@@ -0,0 +1,49 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+ import { clamp01 } from "../utils/math";
4
+
5
+ export function updateParticles(
6
+ elements: SceneElements,
7
+ state: RigState,
8
+ dt: number,
9
+ intensity: number,
10
+ allowGlitch: boolean
11
+ ): void {
12
+ const { audio, particlePulse } = state;
13
+ const particleCount = elements.particleVelocities.length;
14
+
15
+ const glitchChance = allowGlitch ? 0.0002 + intensity * 0.006 : 0;
16
+ const audioJitterBoost = 1 + audio.current * 0.8;
17
+ const particleSpeedMult = (0.3 + intensity * 1.2) * audioJitterBoost;
18
+
19
+ for (let i = 0; i < particleCount; i++) {
20
+ const vel = elements.particleVelocities[i]!;
21
+ let x = elements.particlePos.getX(i) + vel.x * dt * particleSpeedMult;
22
+ let y = elements.particlePos.getY(i) + vel.y * dt * particleSpeedMult;
23
+ let z = elements.particlePos.getZ(i) + vel.z * dt * particleSpeedMult;
24
+
25
+ if (Math.random() < glitchChance) {
26
+ const r = 1.5 + Math.random() * 2;
27
+ const theta = Math.random() * Math.PI * 2;
28
+ const phi = Math.acos(Math.random() * 2 - 1);
29
+ x = r * Math.sin(phi) * Math.cos(theta);
30
+ y = r * Math.sin(phi) * Math.sin(theta);
31
+ z = r * Math.cos(phi);
32
+ }
33
+
34
+ const distSq = x * x + y * y + z * z;
35
+ if (distSq > 20) {
36
+ x *= -0.8;
37
+ y *= -0.8;
38
+ z *= -0.8;
39
+ }
40
+
41
+ elements.particlePos.setXYZ(i, x, y, z);
42
+ }
43
+ elements.particlePos.needsUpdate = true;
44
+
45
+ const idleParticleBoost = particlePulse.brightness * 0.4;
46
+ elements.particleMat.opacity = clamp01(0.3 + intensity * 0.4 + audio.current * 0.25 + idleParticleBoost);
47
+ elements.particleMat.size =
48
+ 0.02 + intensity * 0.015 + audio.current * 0.01 + particlePulse.brightness * 0.02;
49
+ }
@@ -0,0 +1,35 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+ import { clamp01 } from "../utils/math";
4
+
5
+ export function updateRings(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
6
+ const { audio, intensity: intensityState } = state;
7
+
8
+ const orbitScale = 0.75 + intensity * 0.4;
9
+ elements.orbitGroup.scale.setScalar(orbitScale);
10
+
11
+ elements.rings.forEach((ring, i) => {
12
+ const ringIntensity = Math.pow(intensity, 1.35);
13
+ const ringSpeed = ring.speed * (0.4 + ringIntensity * 1.5) + intensityState.spinBoost * (1 + i * 0.2);
14
+ ring.mesh.rotateOnAxis(ring.axis, dt * ringSpeed);
15
+
16
+ const wobbleSpeed = 3 + i * 0.5;
17
+ ring.wobblePhase += dt * wobbleSpeed;
18
+ const wobbleAmount = audio.current * 0.06;
19
+ const wobbleX = Math.sin(ring.wobblePhase) * wobbleAmount;
20
+ const wobbleZ = Math.cos(ring.wobblePhase * 1.3) * wobbleAmount;
21
+ ring.mesh.rotation.x += wobbleX;
22
+ ring.mesh.rotation.z += wobbleZ;
23
+
24
+ const phaseSpeed = 1 + intensity * 3;
25
+ ring.phase += dt * phaseSpeed;
26
+
27
+ const baseOpacity = 0.4 + intensity * 0.4 + i * 0.1;
28
+ if (intensity > 0.1) {
29
+ const wave = Math.sin(ring.phase + i * 1.5) * 0.2 * intensity;
30
+ ring.material.opacity = clamp01(baseOpacity + wave + audio.current * 0.25);
31
+ } else {
32
+ ring.material.opacity = clamp01(baseOpacity + audio.current * 0.25);
33
+ }
34
+ });
35
+ }
@@ -0,0 +1,26 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+ import { clamp01 } from "../utils/math";
4
+
5
+ export function updateSigils(elements: SceneElements, state: RigState, dt: number, intensity: number): void {
6
+ const { phase, audio, tool } = state;
7
+
8
+ for (let i = 0; i < elements.fragments.length; i++) {
9
+ const curr = elements.fragments[i]!;
10
+ const next = elements.fragments[(i + 1) % elements.fragments.length]!;
11
+ elements.sigilPos.setXYZ(i * 2, curr.mesh.position.x, curr.mesh.position.y, curr.mesh.position.z);
12
+ elements.sigilPos.setXYZ(i * 2 + 1, next.mesh.position.x, next.mesh.position.y, next.mesh.position.z);
13
+ }
14
+ elements.sigilPos.needsUpdate = true;
15
+
16
+ const sigilPulseSpeed = 1 + intensity * 2;
17
+ phase.sigilPulse += dt * sigilPulseSpeed;
18
+
19
+ tool.sigilBrightnessBoost += ((tool.active ? 1 : 0) - tool.sigilBrightnessBoost) * dt * 8;
20
+
21
+ const sigilBaseOpacity = 0.2 + intensity * 0.2 + tool.sigilBrightnessBoost * 0.5;
22
+ const sigilPulseAmount = 0.05 + intensity * 0.12;
23
+ elements.sigilMat.opacity = clamp01(
24
+ sigilBaseOpacity + Math.sin(phase.sigilPulse) * sigilPulseAmount + audio.current * 0.2
25
+ );
26
+ }
@@ -0,0 +1,83 @@
1
+ import type { SceneElements } from "../scene/create-scene-elements";
2
+ import type { RigState } from "../state/rig-state";
3
+
4
+ import { STARTUP_BANNER_DURATION_S } from "../../../ui/startup";
5
+
6
+ const SPAWN_DURATION = STARTUP_BANNER_DURATION_S;
7
+
8
+ /** Easing for jitter intensity - high at start, fades quickly */
9
+ function jitterEasing(t: number): number {
10
+ return Math.pow(1 - t, 2);
11
+ }
12
+
13
+ /**
14
+ * Updates the spawn animation state and applies visual effects.
15
+ * Returns the current spawn progress (0-1) for other systems to use.
16
+ */
17
+ export function updateSpawn(elements: SceneElements, state: RigState, dt: number): number {
18
+ const easedProgress = advanceSpawn(state, dt);
19
+ applySpawn(elements, state);
20
+ return easedProgress;
21
+ }
22
+
23
+ /**
24
+ * Advances spawn timers/state (no rendering side effects).
25
+ * Returns the eased progress (0-1).
26
+ */
27
+ export function advanceSpawn(state: RigState, dt: number): number {
28
+ if (state.spawn.complete) return 1;
29
+
30
+ state.spawn.elapsed += dt;
31
+ const rawProgress = Math.min(1, state.spawn.elapsed / SPAWN_DURATION);
32
+ state.spawn.progress = rawProgress;
33
+ state.spawn.glitchIntensity = jitterEasing(rawProgress);
34
+
35
+ // Mark complete when done
36
+ if (rawProgress >= 1) {
37
+ state.spawn.complete = true;
38
+ state.spawn.progress = 1;
39
+ state.spawn.glitchIntensity = 0;
40
+ return 1;
41
+ }
42
+
43
+ return rawProgress;
44
+ }
45
+
46
+ /**
47
+ * Applies spawn animation effects to scene elements using current state.
48
+ * Safe to call multiple times per frame.
49
+ */
50
+ export function applySpawn(elements: SceneElements, state: RigState): void {
51
+ // Minimal startup effect: jitter only (synced to banner reveal duration).
52
+ if (state.spawn.complete) {
53
+ elements.coreGroup.position.x = 0;
54
+ elements.coreGroup.position.y = 0;
55
+ return;
56
+ }
57
+
58
+ const jitterAmount = state.spawn.glitchIntensity * 0.15;
59
+ const jitterX = (Math.random() - 0.5) * jitterAmount;
60
+ const jitterY = (Math.random() - 0.5) * jitterAmount;
61
+ elements.coreGroup.position.x = jitterX;
62
+ elements.coreGroup.position.y = jitterY;
63
+ }
64
+
65
+ /**
66
+ * Resets spawn state for a fresh spawn animation.
67
+ */
68
+ export function resetSpawnState(state: RigState): void {
69
+ state.spawn.progress = 0;
70
+ state.spawn.elapsed = 0;
71
+ state.spawn.complete = false;
72
+ state.spawn.glitchIntensity = 1;
73
+ }
74
+
75
+ /**
76
+ * Skips spawn animation and sets everything to fully spawned.
77
+ */
78
+ export function skipSpawnAnimation(state: RigState): void {
79
+ state.spawn.progress = 1;
80
+ state.spawn.elapsed = SPAWN_DURATION;
81
+ state.spawn.complete = true;
82
+ state.spawn.glitchIntensity = 0;
83
+ }
@@ -0,0 +1,17 @@
1
+ export const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
2
+
3
+ export const clamp01 = (value: number): number => clamp(value, 0, 1);
4
+
5
+ export function lerpColor(current: number, target: number, t: number): number {
6
+ const cr = (current >> 16) & 0xff;
7
+ const cg = (current >> 8) & 0xff;
8
+ const cb = current & 0xff;
9
+ const tr = (target >> 16) & 0xff;
10
+ const tg = (target >> 8) & 0xff;
11
+ const tb = target & 0xff;
12
+ return (
13
+ (Math.round(cr + (tr - cr) * t) << 16) |
14
+ (Math.round(cg + (tg - cg) * t) << 8) |
15
+ Math.round(cb + (tb - cb) * t)
16
+ );
17
+ }
@@ -41,6 +41,10 @@ export function ContentBlockView({
41
41
 
42
42
  // Show full reasoning if enabled
43
43
  if (showFullReasoning) {
44
+ const durationLabel =
45
+ block.durationMs !== undefined
46
+ ? ` · ${formatElapsedTime(block.durationMs, { style: "detailed" })}`
47
+ : "";
44
48
  return (
45
49
  <box
46
50
  flexDirection="column"
@@ -51,6 +55,7 @@ export function ContentBlockView({
51
55
  >
52
56
  <text>
53
57
  <span fg={COLORS.REASONING}>{"REASONING"}</span>
58
+ <span fg={COLORS.REASONING_DIM}>{durationLabel}</span>
54
59
  </text>
55
60
  <code
56
61
  content={cleanedContent}
@@ -74,7 +79,7 @@ export function ContentBlockView({
74
79
  return (
75
80
  <text>
76
81
  <span fg={COLORS.REASONING_DIM}>
77
- {"// REASONING"}
82
+ {"REASONING"}
78
83
  {durationLabel}
79
84
  </span>
80
85
  </text>
@@ -1,18 +1,18 @@
1
1
  import { useMemo } from "react";
2
- import { COLORS } from "../ui/constants";
2
+ import { useToolApprovalForCall } from "../hooks/use-tool-approval";
3
3
  import type { ToolCall } from "../types";
4
+ import { COLORS } from "../ui/constants";
5
+ import { ApprovalPicker } from "./ApprovalPicker";
4
6
  import {
5
- getToolLayout,
7
+ ErrorPreviewView,
8
+ ResultPreviewView,
9
+ ToolBodyView,
10
+ ToolHeaderView,
6
11
  defaultToolLayout,
7
12
  getDefaultAbbreviation,
8
- ToolHeaderView,
9
- ToolBodyView,
10
- ResultPreviewView,
11
- ErrorPreviewView,
12
13
  getStatusBorderColor,
14
+ getToolLayout,
13
15
  } from "./tool-layouts";
14
- import { ApprovalPicker } from "./ApprovalPicker";
15
- import { useToolApprovalForCall } from "../hooks/use-tool-approval";
16
16
 
17
17
  interface ToolCallViewProps {
18
18
  call: ToolCall;
@@ -68,10 +68,7 @@ export function ToolCallView({ call, result, showOutput = true }: ToolCallViewPr
68
68
  const toolName = layout.abbreviation ?? getDefaultAbbreviation(call.name);
69
69
  const borderColor = getStatusBorderColor(call.status);
70
70
 
71
- const customBody = useMemo(() => {
72
- if (!layout.renderBody) return null;
73
- return layout.renderBody({ call, result, showOutput });
74
- }, [layout, call, result, showOutput]);
71
+ const customBody = layout.renderBody ? layout.renderBody({ call, result, showOutput }) : null;
75
72
 
76
73
  return (
77
74
  <box
@@ -1,7 +1,7 @@
1
1
  import { TextAttributes } from "@opentui/core";
2
- import { COLORS } from "../../ui/constants";
3
- import type { ToolHeader, ToolBody, ToolBodyLine } from "./types";
4
2
  import type { ToolCallStatus } from "../../types";
3
+ import { COLORS } from "../../ui/constants";
4
+ import type { ToolBody, ToolBodyLine, ToolHeader } from "./types";
5
5
 
6
6
  interface ToolHeaderViewProps {
7
7
  toolName: string;
@@ -11,11 +11,12 @@ interface ToolHeaderViewProps {
11
11
  }
12
12
 
13
13
  export function ToolHeaderView({ toolName, header, isRunning, toolColor }: ToolHeaderViewProps) {
14
+ const displayName = toolName.toUpperCase();
14
15
  return (
15
16
  <box flexDirection="row" alignItems="center" justifyContent="space-between" width="100%">
16
17
  <text>
17
18
  <span fg={toolColor}>{"↯ "}</span>
18
- <span fg={toolColor}>{toolName}</span>
19
+ <span fg={toolColor}>{displayName}</span>
19
20
  {header?.primary && <span fg={COLORS.TOOL_INPUT_TEXT}>{` ${header.primary}`}</span>}
20
21
  {header?.secondary && (
21
22
  <span