pi-libtui 0.3.2

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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/package.json +55 -0
  4. package/src/activity-presentation.ts +75 -0
  5. package/src/appearance.ts +632 -0
  6. package/src/background-surface.ts +125 -0
  7. package/src/color/palette.ts +186 -0
  8. package/src/color/preview.ts +157 -0
  9. package/src/color/resolver.ts +75 -0
  10. package/src/color/theme.ts +531 -0
  11. package/src/component-stack.ts +247 -0
  12. package/src/content/markdown-code.ts +60 -0
  13. package/src/content/terminal-text.ts +135 -0
  14. package/src/content/text.ts +105 -0
  15. package/src/controls/action-panel.ts +420 -0
  16. package/src/controls/dialog-button-bar.ts +287 -0
  17. package/src/controls/multi-select.ts +467 -0
  18. package/src/controls/picker-panel.ts +447 -0
  19. package/src/controls/screen-icon-actions.ts +134 -0
  20. package/src/controls/searchable-select.ts +394 -0
  21. package/src/controls/select-box.ts +224 -0
  22. package/src/controls/selectable-list.ts +433 -0
  23. package/src/controls/selection-action-bar.ts +382 -0
  24. package/src/controls/semantic-input.ts +23 -0
  25. package/src/controls/tab.ts +189 -0
  26. package/src/cursor.ts +288 -0
  27. package/src/decoration/editor-pills.ts +156 -0
  28. package/src/decoration/glyphs.ts +326 -0
  29. package/src/decoration/pointer-interaction.ts +123 -0
  30. package/src/decoration/powerline-pill.ts +104 -0
  31. package/src/decoration/status.ts +185 -0
  32. package/src/decoration/transient-pill.ts +176 -0
  33. package/src/diff/index.ts +21 -0
  34. package/src/diff/model.ts +76 -0
  35. package/src/diff/parse.ts +310 -0
  36. package/src/diff/pierre-syntax.ts +86 -0
  37. package/src/diff/render.ts +504 -0
  38. package/src/diff/structured.ts +264 -0
  39. package/src/diff/view.ts +75 -0
  40. package/src/editor/chrome.ts +341 -0
  41. package/src/editor/composition.ts +53 -0
  42. package/src/editor/layout.ts +81 -0
  43. package/src/editor/presentation.ts +35 -0
  44. package/src/editor/protocol.ts +188 -0
  45. package/src/editor.ts +41 -0
  46. package/src/extension.ts +30 -0
  47. package/src/folding.ts +192 -0
  48. package/src/host/cursor-bridge.ts +210 -0
  49. package/src/host/editor-bridge.ts +148 -0
  50. package/src/host/extension-host.ts +231 -0
  51. package/src/host/mouse-bridge.ts +212 -0
  52. package/src/host/mouse-dispatch.ts +400 -0
  53. package/src/host/mouse-input.ts +85 -0
  54. package/src/host/mouse-targets.ts +238 -0
  55. package/src/host/pi-layout-adapter.ts +298 -0
  56. package/src/host/pi-native-selection.ts +227 -0
  57. package/src/host/split-pane-bridge.ts +720 -0
  58. package/src/host/transcript-bridge.ts +236 -0
  59. package/src/index.ts +334 -0
  60. package/src/line-layout.ts +6 -0
  61. package/src/motion.ts +1409 -0
  62. package/src/mouse/events.ts +55 -0
  63. package/src/mouse/fullscreen-layout.ts +260 -0
  64. package/src/mouse/registry.ts +348 -0
  65. package/src/mouse.ts +37 -0
  66. package/src/native-binary.ts +189 -0
  67. package/src/overlay/anchored.ts +91 -0
  68. package/src/overlay/detail-card.ts +158 -0
  69. package/src/overlay/dialog.ts +266 -0
  70. package/src/overlay/floating.ts +106 -0
  71. package/src/overlay/framed-editor.ts +236 -0
  72. package/src/overlay/fullscreen.ts +170 -0
  73. package/src/overlay/hover-tooltip.ts +109 -0
  74. package/src/overlay/modal-mount.ts +118 -0
  75. package/src/panels.ts +207 -0
  76. package/src/render-cache.ts +80 -0
  77. package/src/render-epoch.ts +48 -0
  78. package/src/request-animation.ts +160 -0
  79. package/src/scrollbar.ts +27 -0
  80. package/src/selection.ts +148 -0
  81. package/src/split-pane.ts +232 -0
  82. package/src/status-presentation.ts +411 -0
  83. package/src/stream.ts +543 -0
  84. package/src/syntax.ts +316 -0
  85. package/src/terminal/bridge-client.ts +243 -0
  86. package/src/terminal/embedding.ts +6 -0
  87. package/src/terminal/output.ts +151 -0
  88. package/src/terminal/projection.ts +362 -0
  89. package/src/terminal/pty-host.ts +263 -0
  90. package/src/terminal/pty-pane.ts +308 -0
  91. package/src/terminal-colors.ts +334 -0
  92. package/src/tool/action.ts +170 -0
  93. package/src/tool/activity.ts +500 -0
  94. package/src/tool/call-preview.ts +44 -0
  95. package/src/tool/disclosure-action.ts +89 -0
  96. package/src/tool/expanded-region-viewport.ts +103 -0
  97. package/src/tool/index.ts +26 -0
  98. package/src/tool/output.ts +271 -0
  99. package/src/tool/transcript.ts +150 -0
  100. package/src/tool/view-region.ts +581 -0
  101. package/test/appearance.test.ts +183 -0
  102. package/test/bounded-stream.test.ts +150 -0
  103. package/test/bridge-client.test.ts +42 -0
  104. package/test/color-preview.test.ts +23 -0
  105. package/test/component-stack.test.ts +206 -0
  106. package/test/components.test.ts +1165 -0
  107. package/test/content-text.test.ts +21 -0
  108. package/test/cursor-bridge.test.ts +151 -0
  109. package/test/cursor.test.ts +147 -0
  110. package/test/editor-chrome.test.ts +119 -0
  111. package/test/editor-layout.test.ts +30 -0
  112. package/test/editor-registry.test.ts +203 -0
  113. package/test/editor-theme.test.ts +33 -0
  114. package/test/editor-token-pill.test.ts +72 -0
  115. package/test/floating-overlay.test.ts +57 -0
  116. package/test/folding.test.ts +147 -0
  117. package/test/harmonious-theme.test.ts +23 -0
  118. package/test/interaction.test.ts +104 -0
  119. package/test/key-icons.test.ts +62 -0
  120. package/test/markdown-code.test.ts +24 -0
  121. package/test/modal-overlay-mount.test.ts +94 -0
  122. package/test/motion.test.ts +649 -0
  123. package/test/mouse/bridge.test.ts +1302 -0
  124. package/test/mouse/extension.test.ts +205 -0
  125. package/test/mouse/registry.test.ts +140 -0
  126. package/test/mouse/screen-decoration.test.ts +167 -0
  127. package/test/native-binary.test.ts +52 -0
  128. package/test/palette.test.ts +32 -0
  129. package/test/panels.test.ts +74 -0
  130. package/test/pointer-components.test.ts +151 -0
  131. package/test/progress.test.ts +54 -0
  132. package/test/pty-host.test.ts +126 -0
  133. package/test/pty-pane.test.ts +62 -0
  134. package/test/public-surface.test.ts +75 -0
  135. package/test/render-cache.test.ts +25 -0
  136. package/test/render-epoch.test.ts +119 -0
  137. package/test/request-animation.test.ts +193 -0
  138. package/test/screen-icon-actions.test.ts +69 -0
  139. package/test/select-box.test.ts +86 -0
  140. package/test/select-pointer.test.ts +369 -0
  141. package/test/selectable-list.test.ts +258 -0
  142. package/test/selection/registry.test.ts +44 -0
  143. package/test/selection-action-bar.test.ts +204 -0
  144. package/test/split-pane-bridge.test.ts +944 -0
  145. package/test/split-pane.test.ts +134 -0
  146. package/test/status-presentation.test.ts +39 -0
  147. package/test/syntax.test.ts +59 -0
  148. package/test/terminal-colors.test.ts +115 -0
  149. package/test/terminal-embedding.test.ts +12 -0
  150. package/test/terminal-projection.test.ts +312 -0
  151. package/test/theme-colors.test.ts +175 -0
  152. package/test/tool-output.test.ts +160 -0
  153. package/test/tool-transcript.test.ts +1190 -0
  154. package/test/unified-diff.test.ts +691 -0
  155. package/themes/harmonious.json +61 -0
  156. package/tsconfig.json +16 -0
@@ -0,0 +1,649 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import { visibleWidth } from "@earendil-works/pi-tui";
4
+ import { configureTuiAppearance, DEFAULT_TUI_APPEARANCE } from "../src/appearance.ts";
5
+ import { tuiTheme } from "../src/color/theme.ts";
6
+ import type { MotionClock, MotionRenderTarget, MotionTimerHandle } from "../src/motion.ts";
7
+ import {
8
+ activityFrame,
9
+ animationSmoothnessCadenceMs,
10
+ animationSpeedMultiplier,
11
+ configuredAnimationCadenceMs,
12
+ glyphFrame,
13
+ lightningShimmerFrame,
14
+ MotionScheduler,
15
+ mountConfiguredAnimation,
16
+ pulseFrame,
17
+ pulseGlyphFrame,
18
+ rainbowGlowShimmerFrame,
19
+ rainbowShimmerFrame,
20
+ shimmerFrame,
21
+ spinnerFrame,
22
+ } from "../src/motion.ts";
23
+
24
+ const theme = {
25
+ name: "motion-test",
26
+ getColorMode: () => "truecolor",
27
+ getFgAnsi: (token: string) => (token === "accent" ? "\x1b[38;2;80;160;240m" : "\x1b[38;2;180;180;180m"),
28
+ getBgAnsi: () => "\x1b[48;2;20;20;20m",
29
+ } as never as Theme;
30
+
31
+ afterEach(() => configureTuiAppearance(DEFAULT_TUI_APPEARANCE));
32
+
33
+ interface FakeTimer extends MotionTimerHandle {
34
+ callback: () => void;
35
+ cadenceMs: number;
36
+ unreferenced: boolean;
37
+ stopped: boolean;
38
+ }
39
+
40
+ class FakeClock implements MotionClock {
41
+ currentMs = 0;
42
+ readonly timers: FakeTimer[] = [];
43
+
44
+ now(): number {
45
+ return this.currentMs;
46
+ }
47
+
48
+ start(callback: () => void, cadenceMs: number): FakeTimer {
49
+ const timer: FakeTimer = {
50
+ callback,
51
+ cadenceMs,
52
+ unreferenced: false,
53
+ stopped: false,
54
+ unref() {
55
+ this.unreferenced = true;
56
+ },
57
+ };
58
+ this.timers.push(timer);
59
+ return timer;
60
+ }
61
+
62
+ stop(handle: MotionTimerHandle): void {
63
+ (handle as FakeTimer).stopped = true;
64
+ }
65
+
66
+ tick(cadenceMs: number, nowMs: number): void {
67
+ this.currentMs = nowMs;
68
+ for (const timer of this.timers) {
69
+ if (!timer.stopped && timer.cadenceMs === cadenceMs) timer.callback();
70
+ }
71
+ }
72
+ }
73
+
74
+ describe("MotionScheduler", () => {
75
+ test("shares unreferenced cadence timers and releases them by reference count", () => {
76
+ const clock = new FakeClock();
77
+ const scheduler = new MotionScheduler(clock);
78
+ const target = { requestRender() {} };
79
+ const first = scheduler.mount(target, { cadenceMs: 80 });
80
+ const second = scheduler.mount(target, { cadenceMs: 80 });
81
+
82
+ expect(clock.timers).toHaveLength(1);
83
+ expect(clock.timers[0]!.unreferenced).toBe(true);
84
+ expect(scheduler.activeMountCount).toBe(2);
85
+ let renders = 0;
86
+ const coalescedTarget = { requestRender: () => renders++ };
87
+ const third = scheduler.mount(coalescedTarget, { cadenceMs: 80 });
88
+ const fourth = scheduler.mount(coalescedTarget, { cadenceMs: 80 });
89
+ clock.tick(80, 80);
90
+ expect(renders).toBe(1);
91
+ first.dispose();
92
+ expect(clock.timers[0]!.stopped).toBe(false);
93
+ second.dispose();
94
+ expect(clock.timers[0]!.stopped).toBe(false);
95
+ third.dispose();
96
+ fourth.dispose();
97
+ expect(clock.timers[0]!.stopped).toBe(true);
98
+ expect(scheduler.activeTimerCount).toBe(0);
99
+ });
100
+
101
+ test("coalesces target invalidation onto its fastest cadence while advancing slower callbacks", () => {
102
+ const clock = new FakeClock();
103
+ const scheduler = new MotionScheduler(clock);
104
+ let renders = 0;
105
+ const frames: number[] = [];
106
+ const target = { requestRender: () => renders++ };
107
+ const fast = scheduler.mount(target, { cadenceMs: 40 });
108
+ const slow = scheduler.mount(target, { cadenceMs: 100, onFrame: (now) => frames.push(now) });
109
+
110
+ clock.tick(100, 100);
111
+ expect(frames).toEqual([100]);
112
+ expect(renders).toBe(0);
113
+ clock.tick(40, 120);
114
+ expect(renders).toBe(1);
115
+ fast.dispose();
116
+ clock.tick(100, 200);
117
+ expect(renders).toBe(2);
118
+ slow.dispose();
119
+ });
120
+
121
+ test("coalesces distinct component wrappers that share one host repaint callback", () => {
122
+ const clock = new FakeClock();
123
+ const scheduler = new MotionScheduler(clock);
124
+ let renders = 0;
125
+ const requestRender = () => renders++;
126
+ const first = scheduler.mount({ requestRender }, { cadenceMs: 80 });
127
+ const second = scheduler.mount({ requestRender }, { cadenceMs: 80 });
128
+
129
+ clock.tick(80, 80);
130
+
131
+ expect(renders).toBe(1);
132
+ first.dispose();
133
+ second.dispose();
134
+ });
135
+
136
+ test("preserves the receiver of class-based render targets", () => {
137
+ const clock = new FakeClock();
138
+ const scheduler = new MotionScheduler(clock);
139
+ class MethodTarget implements MotionRenderTarget {
140
+ renders = 0;
141
+
142
+ requestRender(): void {
143
+ this.renders += 1;
144
+ }
145
+ }
146
+ const target = new MethodTarget();
147
+ const mount = scheduler.mount(target, { cadenceMs: 80 });
148
+
149
+ clock.tick(80, 80);
150
+
151
+ expect(target.renders).toBe(1);
152
+ mount.dispose();
153
+ });
154
+
155
+ test("reduced motion allocates no timer", () => {
156
+ const clock = new FakeClock();
157
+ const scheduler = new MotionScheduler(clock);
158
+ const mount = scheduler.mount({ requestRender() {} }, { cadenceMs: 40, reducedMotion: true });
159
+ expect(scheduler.activeTimerCount).toBe(0);
160
+ mount.dispose();
161
+ });
162
+
163
+ test("pauses and resumes registered animations without dropping them", () => {
164
+ const clock = new FakeClock();
165
+ const scheduler = new MotionScheduler(clock);
166
+ let renders = 0;
167
+ const mount = scheduler.mount({ requestRender: () => renders++ }, { cadenceMs: 40 });
168
+
169
+ scheduler.setPaused(true);
170
+ expect(scheduler.activeTimerCount).toBe(0);
171
+ clock.tick(40, 40);
172
+ expect(renders).toBe(0);
173
+ expect(scheduler.activeMountCount).toBe(1);
174
+
175
+ scheduler.setPaused(false);
176
+ expect(scheduler.activeTimerCount).toBe(1);
177
+ clock.tick(40, 80);
178
+ expect(renders).toBe(1);
179
+ mount.dispose();
180
+ });
181
+
182
+ test("expires abandoned mounts without replaying stale animation frames", () => {
183
+ const clock = new FakeClock();
184
+ const scheduler = new MotionScheduler(clock);
185
+ let renders = 0;
186
+ let frames = 0;
187
+ const mount = scheduler.mount(
188
+ { requestRender: () => renders++ },
189
+ { cadenceMs: 40, maxDurationMs: 100, onFrame: () => frames++ },
190
+ );
191
+
192
+ clock.tick(40, 80);
193
+ expect({ renders, frames }).toEqual({ renders: 1, frames: 1 });
194
+ clock.tick(40, 100);
195
+ expect({ renders, frames }).toEqual({ renders: 1, frames: 1 });
196
+ expect(scheduler.activeMountCount).toBe(0);
197
+ expect(scheduler.activeTimerCount).toBe(0);
198
+ expect(clock.timers[0]!.stopped).toBe(true);
199
+ mount.dispose();
200
+ expect(scheduler.activeMountCount).toBe(0);
201
+ });
202
+
203
+ test("normalizes invalid cadence and retires throwing callbacks", () => {
204
+ const clock = new FakeClock();
205
+ const scheduler = new MotionScheduler(clock);
206
+ scheduler.mount(
207
+ { requestRender: () => {} },
208
+ {
209
+ cadenceMs: Number.NaN,
210
+ onFrame() {
211
+ throw new Error("broken animation");
212
+ },
213
+ },
214
+ );
215
+ expect(clock.timers[0]!.cadenceMs).toBe(120);
216
+ expect(() => clock.tick(120, 120)).not.toThrow();
217
+ expect(scheduler.activeMountCount).toBe(0);
218
+ expect(scheduler.activeTimerCount).toBe(0);
219
+ });
220
+ });
221
+
222
+ describe("pure motion frames", () => {
223
+ test("renders activity text independently from optional markers", () => {
224
+ const colors = tuiTheme(theme);
225
+ const spinner = activityFrame(colors, "Working", 0, { indicatorStyle: "spinner", textEffectStyle: "off" });
226
+ const pulseGlow = activityFrame(colors, "Working", 300, {
227
+ indicatorStyle: "static",
228
+ textEffectStyle: "glow",
229
+ pulseEffectStyle: "color",
230
+ });
231
+ const markerlessSweep = activityFrame(colors, "Working", 140, { indicatorStyle: "off", textEffectStyle: "sweep" });
232
+ const staticGlow = activityFrame(colors, "Working", 300, { indicatorStyle: "static", textEffectStyle: "glow" });
233
+ const lineRainbow = activityFrame(colors, "Working", 140, { indicatorStyle: "line", textEffectStyle: "rainbow" });
234
+ const arc = activityFrame(colors, "Working", 160, { indicatorStyle: "arc", textEffectStyle: "off" });
235
+ const dots = activityFrame(colors, "Working", 240, { indicatorStyle: "dots", textEffectStyle: "off" });
236
+ const quadrants = activityFrame(colors, "Working", 200, { indicatorStyle: "quadrants", textEffectStyle: "off" });
237
+ const sparkle = activityFrame(colors, "Working", 240, { indicatorStyle: "sparkle", textEffectStyle: "off" });
238
+ const staticFrame = activityFrame(colors, "Working", 10_000, { indicatorStyle: "static", textEffectStyle: "off" });
239
+
240
+ expect(Bun.stripANSI(spinner.marker)).toBe("⠋");
241
+ expect(Bun.stripANSI(pulseGlow.marker)).toBe("●");
242
+ expect(markerlessSweep.marker).toBe("");
243
+ expect(Bun.stripANSI(markerlessSweep.text)).toBe("Working");
244
+ expect(Bun.stripANSI(staticGlow.marker)).toBe("●");
245
+ expect(["-", "\\", "|", "/"]).toContain(Bun.stripANSI(lineRainbow.marker));
246
+ expect(Bun.stripANSI(arc.marker)).toBe("◠");
247
+ expect(Bun.stripANSI(dots.marker)).toBe("⡀");
248
+ expect(Bun.stripANSI(quadrants.marker)).toBe("▝");
249
+ expect(Bun.stripANSI(sparkle.marker)).toBe("✧");
250
+ expect(Bun.stripANSI(lineRainbow.text)).toBe("Working");
251
+ expect(lineRainbow.text).not.toBe(colors.fg("text.primary", "Working"));
252
+ expect(Bun.stripANSI(staticGlow.text)).toBe(Bun.stripANSI(pulseGlow.text));
253
+ expect(staticGlow.text).not.toBe(pulseGlow.text);
254
+ expect(Bun.stripANSI(staticFrame.marker)).toBe("●");
255
+ expect(Bun.stripANSI(staticFrame.text)).toBe("Working");
256
+ expect(staticFrame.text).not.toBe(staticGlow.text);
257
+ });
258
+
259
+ test("separates animation speed from repaint smoothness and stops static activity", () => {
260
+ expect(animationSpeedMultiplier("slow")).toBe(0.6);
261
+ expect(animationSpeedMultiplier("relaxed")).toBe(0.8);
262
+ expect(animationSpeedMultiplier("normal")).toBe(1);
263
+ expect(animationSpeedMultiplier("fast")).toBe(1.35);
264
+ expect(animationSpeedMultiplier("very-fast")).toBe(1.7);
265
+ expect(animationSmoothnessCadenceMs("economy")).toBe(80);
266
+ expect(animationSmoothnessCadenceMs("balanced")).toBe(33);
267
+ expect(animationSmoothnessCadenceMs("smooth")).toBe(22);
268
+ expect(animationSmoothnessCadenceMs("ultra")).toBe(16);
269
+ expect(configuredAnimationCadenceMs("spinner", "off")).toBe(80);
270
+ expect(configuredAnimationCadenceMs("off", "lightning", "ultra")).toBe(16);
271
+ expect(configuredAnimationCadenceMs("off", "lightning", "ultra", "very-fast")).toBe(16);
272
+ expect(configuredAnimationCadenceMs("static", "off", "economy", "normal", "pulse")).toBe(80);
273
+ expect(configuredAnimationCadenceMs("static", "off", "economy", "normal", "color")).toBe(80);
274
+ expect(configuredAnimationCadenceMs("static", "off")).toBeUndefined();
275
+
276
+ const colors = tuiTheme(theme);
277
+ const slow = activityFrame(colors, "Working", 200, {
278
+ indicatorStyle: "line",
279
+ textEffectStyle: "off",
280
+ animationSpeed: "slow",
281
+ });
282
+ const fast = activityFrame(colors, "Working", 200, {
283
+ indicatorStyle: "line",
284
+ textEffectStyle: "off",
285
+ animationSpeed: "fast",
286
+ });
287
+ expect(Bun.stripANSI(slow.marker)).toBe("\\");
288
+ expect(Bun.stripANSI(fast.marker)).toBe("|");
289
+
290
+ const clock = new FakeClock();
291
+ const scheduler = new MotionScheduler(clock);
292
+ let renders = 0;
293
+ configureTuiAppearance({ activityIndicator: "static", textEffect: "off" });
294
+ const mount = mountConfiguredAnimation({ requestRender: () => renders++ }, { scheduler });
295
+ expect(scheduler.activeTimerCount).toBe(0);
296
+
297
+ configureTuiAppearance({
298
+ activityIndicator: "off",
299
+ textEffect: "lightning",
300
+ animationSpeed: "very-fast",
301
+ animationSmoothness: "ultra",
302
+ });
303
+ expect(clock.timers.at(-1)?.cadenceMs).toBe(16);
304
+ expect(scheduler.activeTimerCount).toBe(1);
305
+ expect(renders).toBe(1);
306
+
307
+ configureTuiAppearance({ activityIndicator: "static", textEffect: "off" });
308
+ expect(scheduler.activeTimerCount).toBe(0);
309
+ expect(renders).toBe(2);
310
+ mount.dispose();
311
+
312
+ configureTuiAppearance({ activityIndicator: "spinner", textEffect: "off" });
313
+ const markerless = mountConfiguredAnimation(
314
+ { requestRender: () => renders++ },
315
+ { scheduler, indicatorStyle: "off" },
316
+ );
317
+ expect(scheduler.activeTimerCount).toBe(0);
318
+ configureTuiAppearance({ activityIndicator: "off", textEffect: "off" });
319
+ const explicitSpinner = mountConfiguredAnimation(
320
+ { requestRender: () => renders++ },
321
+ { scheduler, indicatorStyle: "spinner" },
322
+ );
323
+ expect(scheduler.activeTimerCount).toBe(1);
324
+ markerless.dispose();
325
+ explicitSpinner.dispose();
326
+ });
327
+
328
+ test("samples marker and shimmer from one elapsed timeline without changing either pace", () => {
329
+ const colors = tuiTheme(theme);
330
+ const markerOnly = activityFrame(colors, "Working", 140, {
331
+ indicatorStyle: "spinner",
332
+ textEffectStyle: "off",
333
+ });
334
+ const combined = activityFrame(colors, "Working", 140, {
335
+ indicatorStyle: "spinner",
336
+ textEffectStyle: "glow",
337
+ });
338
+ const nextCombined = activityFrame(colors, "Working", 280, {
339
+ indicatorStyle: "spinner",
340
+ textEffectStyle: "glow",
341
+ });
342
+
343
+ expect(Bun.stripANSI(markerOnly.marker)).toBe("⠙");
344
+ expect(Bun.stripANSI(combined.marker)).toBe("⠙");
345
+ expect(Bun.stripANSI(nextCombined.marker)).toBe("⠸");
346
+ expect(nextCombined.text).not.toBe(combined.text);
347
+ });
348
+
349
+ test("samples lightning artifacts on every configured repaint without speeding its strike", () => {
350
+ const colors = tuiTheme(theme);
351
+ for (const animationSpeed of ["slow", "normal", "very-fast"] as const) {
352
+ const frames = Array.from({ length: 12 }, (_, index) => {
353
+ const frame = activityFrame(colors, "Working...", index * 33, {
354
+ indicatorStyle: "static",
355
+ textEffectStyle: "lightning",
356
+ textEffectScope: "inline",
357
+ animationSpeed,
358
+ animationSmoothness: "balanced",
359
+ });
360
+ return `${frame.marker}${frame.text}`;
361
+ });
362
+ expect(new Set(frames).size).toBe(frames.length);
363
+ }
364
+ });
365
+
366
+ test("keeps every compact marker at its declared width", () => {
367
+ const colors = tuiTheme(theme);
368
+ const styles = [
369
+ ["pipe", 1],
370
+ ["grow-vertical", 1],
371
+ ["grow-horizontal", 1],
372
+ ["triangle", 1],
373
+ ["circle-quarters", 1],
374
+ ["circle-halves", 1],
375
+ ["bracket-spin", 1],
376
+ ["braille-wave", 4],
377
+ ["braille-dna", 4],
378
+ ["braille-scan", 4],
379
+ ["braille-rain", 4],
380
+ ["braille-scanline", 3],
381
+ ["braille-pulse", 3],
382
+ ["braille-sparkle", 4],
383
+ ["braille-cascade", 4],
384
+ ["braille-columns", 3],
385
+ ["braille-orbit", 1],
386
+ ["braille-breathe", 1],
387
+ ["braille-wave-rows", 4],
388
+ ["braille-checkerboard", 3],
389
+ ["braille-helix", 4],
390
+ ["scanline", 3],
391
+ ["snake", 2],
392
+ ["fill-sweep", 2],
393
+ ["diagonal-swipe", 2],
394
+ ["dna", 3],
395
+ ["radar", 3],
396
+ ["bounce", 3],
397
+ ["orbit", 3],
398
+ ["conveyor", 3],
399
+ ["heartbeat", 3],
400
+ ["nerd-progress", 1],
401
+ ["nerd-morph", 1],
402
+ ["nerd-pipeline", 3],
403
+ ["nerd-pi-orbit", 3],
404
+ ] as const;
405
+ configureTuiAppearance({ iconPack: "nerd-fonts" });
406
+ for (const [indicatorStyle, width] of styles) {
407
+ for (let elapsedMs = 0; elapsedMs < 1_000; elapsedMs += 70) {
408
+ const frame = activityFrame(colors, "Working", elapsedMs, { indicatorStyle, textEffectStyle: "off" });
409
+ expect(visibleWidth(Bun.stripANSI(frame.marker))).toBe(width);
410
+ expect(Bun.stripANSI(frame.text)).toBe("Working");
411
+ }
412
+ }
413
+ });
414
+
415
+ test("keeps Unicode arc separate from the Nerd Font progress spinner", () => {
416
+ const colors = tuiTheme(theme);
417
+ configureTuiAppearance({ iconPack: "unicode" });
418
+ expect(
419
+ Bun.stripANSI(activityFrame(colors, "Working", 0, { indicatorStyle: "arc", textEffectStyle: "off" }).marker),
420
+ ).toBe("◜");
421
+
422
+ configureTuiAppearance({ iconPack: "nerd-fonts" });
423
+ expect(
424
+ Bun.stripANSI(activityFrame(colors, "Working", 0, { indicatorStyle: "arc", textEffectStyle: "off" }).marker),
425
+ ).toBe("◜");
426
+ expect(
427
+ Bun.stripANSI(
428
+ activityFrame(colors, "Working", 450, { indicatorStyle: "nerd-progress", textEffectStyle: "off" }).marker,
429
+ ),
430
+ ).toBe("");
431
+ });
432
+
433
+ test("renders the referenced four-cell Braille wave at 100 ms cadence", () => {
434
+ const colors = tuiTheme(theme);
435
+ const expected = ["⠁⠂⠄⡀", "⠂⠄⡀⢀", "⠄⡀⢀⠠", "⡀⢀⠠⠐", "⢀⠠⠐⠈", "⠠⠐⠈⠁", "⠐⠈⠁⠂", "⠈⠁⠂⠄"];
436
+ for (const [index, frame] of expected.entries()) {
437
+ expect(
438
+ Bun.stripANSI(
439
+ activityFrame(colors, "Working", index * 100, { indicatorStyle: "braille-wave", textEffectStyle: "off" })
440
+ .marker,
441
+ ),
442
+ ).toBe(frame);
443
+ }
444
+ });
445
+
446
+ test("falls back from Nerd Font markers only when that icon pack is inactive", () => {
447
+ const colors = tuiTheme(theme);
448
+ configureTuiAppearance({ iconPack: "unicode" });
449
+ const fallback = Bun.stripANSI(
450
+ activityFrame(colors, "Working", 0, { indicatorStyle: "nerd-pipeline", textEffectStyle: "off" }).marker,
451
+ );
452
+ expect(fallback).toBe("| ");
453
+
454
+ configureTuiAppearance({ iconPack: "nerd-fonts" });
455
+ const nerdFont = Bun.stripANSI(
456
+ activityFrame(colors, "Working", 0, { indicatorStyle: "nerd-pipeline", textEffectStyle: "off" }).marker,
457
+ );
458
+ expect(nerdFont).toBe("\uf0e7--");
459
+ });
460
+
461
+ test("spinner and glyph frames are deterministic and freeze under reduced motion", () => {
462
+ expect(spinnerFrame(160)).toBe(spinnerFrame(160));
463
+ expect(glyphFrame(["a", "b"], 120, 100)).toBe("b");
464
+ expect(glyphFrame(["a", "b"], 10_000, 100, true)).toBe("a");
465
+ });
466
+
467
+ test("pulse stays within caller bounds", () => {
468
+ for (let now = 0; now < 10_000; now += 17) {
469
+ const frame = pulseFrame(now, { low: 0.2, high: 0.8 });
470
+ expect(frame).toBeGreaterThanOrEqual(0.2);
471
+ expect(frame).toBeLessThanOrEqual(0.8);
472
+ }
473
+ });
474
+
475
+ test("color pulse composes over a stable indicator and text", () => {
476
+ const colors = tuiTheme(theme);
477
+ const frames = Array.from({ length: 37 }, (_, index) => pulseGlyphFrame(colors, "●", index * 33));
478
+ const foregrounds = new Set(frames.flatMap((frame) => frame.match(/\x1b\[38;[^m]+m/gu) ?? []));
479
+
480
+ expect(foregrounds.size).toBeGreaterThan(20);
481
+ const samples = [0, 150, 300, 450, 600].map((elapsedMs) =>
482
+ activityFrame(colors, "Working", elapsedMs, {
483
+ indicatorStyle: "static",
484
+ textEffectStyle: "glow",
485
+ pulseEffectStyle: "color",
486
+ }),
487
+ );
488
+ const markers = samples.map((sample) => sample.marker);
489
+ expect(markers.map(Bun.stripANSI)).toEqual(["●", "●", "●", "●", "●"]);
490
+ expect(new Set(markers).size).toBeGreaterThan(2);
491
+ expect(new Set(samples.map((sample) => sample.text)).size).toBeGreaterThan(2);
492
+ });
493
+
494
+ test("pulse fades the composed indicator and text from dim to bright without changing glyphs", () => {
495
+ const colors = tuiTheme(theme);
496
+ const dim = activityFrame(colors, "Working", 0, {
497
+ indicatorStyle: "static",
498
+ textEffectStyle: "off",
499
+ pulseEffectStyle: "pulse",
500
+ });
501
+ const bright = activityFrame(colors, "Working", 600, {
502
+ indicatorStyle: "static",
503
+ textEffectStyle: "off",
504
+ pulseEffectStyle: "pulse",
505
+ });
506
+
507
+ expect(Bun.stripANSI(dim.marker)).toBe("●");
508
+ expect(Bun.stripANSI(bright.marker)).toBe("●");
509
+ expect(Bun.stripANSI(dim.text)).toBe("Working");
510
+ expect(Bun.stripANSI(bright.text)).toBe("Working");
511
+ expect(dim.marker).not.toBe(bright.marker);
512
+ expect(dim.text).not.toBe(bright.text);
513
+ expect(dim.marker).toContain(colors.fgAnsi(colors.adjustForegroundBrightness("accent", -0.4)));
514
+ expect(bright.marker).toContain(colors.fgAnsi(colors.adjustForegroundBrightness("accent", 0.18)));
515
+ });
516
+
517
+ test("shimmer reaches the final cell with a continuous cosine glow", () => {
518
+ const colors = tuiTheme(theme);
519
+ const text = "working status";
520
+ const final = shimmerFrame(colors, text, ((text.length - 1 + 10) / 30) * 1_000);
521
+ const glow = shimmerFrame(colors, text, ((6 + 10) / 30) * 1_000);
522
+
523
+ expect(final).toContain(colors.fg("accent", "s"));
524
+ expect(new Set(glow.match(/\x1b\[38;[^m]+m/gu) ?? []).size).toBeGreaterThan(5);
525
+ expect(shimmerFrame(colors, text, 10)).toBe(colors.fg("text.secondary", text));
526
+ });
527
+
528
+ test("glow chooses a visible contrast when its base text already uses the accent", () => {
529
+ const colors = tuiTheme(theme);
530
+ const glow = shimmerFrame(colors, "accent text", (10 / 30) * 1_000, { baseTone: "accent" });
531
+
532
+ expect(Bun.stripANSI(glow)).toBe("accent text");
533
+ expect(glow).not.toBe(colors.fg("accent", "accent text"));
534
+ expect(new Set(glow.match(/\x1b\[38;[^m]+m/gu) ?? []).size).toBeGreaterThan(3);
535
+ });
536
+
537
+ test("shimmers marker and text through one shared color ramp", () => {
538
+ const colors = tuiTheme(theme);
539
+ const atMarker = activityFrame(colors, "Working", (10 / 30) * 1_000, {
540
+ indicatorStyle: "static",
541
+ textEffectStyle: "glow",
542
+ textEffectScope: "inline",
543
+ });
544
+ const atText = activityFrame(colors, "Working", (12 / 30) * 1_000, {
545
+ indicatorStyle: "static",
546
+ textEffectStyle: "glow",
547
+ textEffectScope: "inline",
548
+ });
549
+
550
+ expect(atMarker.marker).toContain(colors.fgAnsi("accent"));
551
+ expect(atText.text).toContain(colors.fg("accent", "W"));
552
+ expect(atMarker.text).toContain(colors.fgAnsi("text.muted"));
553
+ });
554
+
555
+ test("rainbow shimmer preserves text while moving through semantic hues", () => {
556
+ const colors = tuiTheme(theme);
557
+ const first = rainbowShimmerFrame(colors, "Working", 0);
558
+ const later = rainbowShimmerFrame(colors, "Working", 320);
559
+ expect(Bun.stripANSI(first)).toBe("Working");
560
+ expect(Bun.stripANSI(later)).toBe("Working");
561
+ expect(later).not.toBe(first);
562
+ });
563
+
564
+ test("rainbow glow preserves text while moving a broad color highlight", () => {
565
+ const colors = tuiTheme(theme);
566
+ const first = rainbowGlowShimmerFrame(colors, "Working", 0);
567
+ const later = rainbowGlowShimmerFrame(colors, "Working", 320);
568
+ expect(Bun.stripANSI(first)).toBe("Working");
569
+ expect(Bun.stripANSI(later)).toBe("Working");
570
+ expect(later).not.toBe(first);
571
+ });
572
+
573
+ test("lightning ports the fast-mode reverse strike and ASCII variants", () => {
574
+ const colors = tuiTheme(theme);
575
+ const first = lightningShimmerFrame(colors, "Working", 0);
576
+ const later = lightningShimmerFrame(colors, "Working", 120);
577
+ expect(Bun.stripANSI(first).normalize("NFD")).toBe("W\u0307o\u0307r\u0307k\u0307in\u0307g\u0307".normalize("NFD"));
578
+ expect(visibleWidth(Bun.stripANSI(first))).toBe(visibleWidth("Working"));
579
+ expect(Bun.stripANSI(later)).not.toBe(Bun.stripANSI(first));
580
+ expect(first).toContain("\x1b[1;9m");
581
+ expect(first).toContain("\x1b[9m");
582
+ });
583
+
584
+ test("lightning gives every printable ASCII character nine fixed-width variants", () => {
585
+ const colors = tuiTheme(theme);
586
+ for (let codePoint = 0x20; codePoint <= 0x7e; codePoint++) {
587
+ const character = String.fromCharCode(codePoint);
588
+ const variants = Array.from({ length: 9 }, (_, frame) =>
589
+ Bun.stripANSI(lightningShimmerFrame(colors, character, frame * 60)),
590
+ );
591
+ expect(new Set(variants).size).toBe(9);
592
+ for (const variant of variants) expect(visibleWidth(variant)).toBe(1);
593
+ }
594
+ });
595
+
596
+ test("marker shimmer crosses marker, separator, and text as one unit", () => {
597
+ const colors = tuiTheme(theme);
598
+ const plain = activityFrame(colors, "AB", 0, {
599
+ indicatorStyle: "line",
600
+ textEffectStyle: "lightning",
601
+ textEffectScope: "message",
602
+ });
603
+ const textStrike = activityFrame(colors, "AB", 0, {
604
+ indicatorStyle: "line",
605
+ textEffectStyle: "lightning",
606
+ textEffectScope: "inline",
607
+ });
608
+ const markerStrike = activityFrame(colors, "AB", 360, {
609
+ indicatorStyle: "line",
610
+ textEffectStyle: "lightning",
611
+ textEffectScope: "inline",
612
+ });
613
+ expect(Bun.stripANSI(textStrike.marker).normalize("NFD").replace(/\p{M}/gu, "")).toBe(Bun.stripANSI(plain.marker));
614
+ expect(visibleWidth(Bun.stripANSI(textStrike.marker))).toBe(1);
615
+ expect(Bun.stripANSI(textStrike.marker)).not.toBe(Bun.stripANSI(plain.marker));
616
+ expect(textStrike.marker).not.toContain("\x1b[1;9m");
617
+ expect(textStrike.text).toContain("\x1b[1;9m");
618
+ expect(markerStrike.marker).toContain("\x1b[1;9m");
619
+ });
620
+
621
+ test("shimmer keeps grapheme clusters intact", () => {
622
+ const colors = tuiTheme(theme);
623
+ const text = "e\u0301 👩🏽‍💻";
624
+ for (const frame of [
625
+ shimmerFrame(colors, text, 70),
626
+ rainbowShimmerFrame(colors, text, 80),
627
+ rainbowGlowShimmerFrame(colors, text, 80),
628
+ lightningShimmerFrame(colors, text, 60, { variantAscii: false }),
629
+ ]) {
630
+ expect(Bun.stripANSI(frame)).toBe(text);
631
+ expect(frame).not.toContain("e\u001b[0m\u0301");
632
+ }
633
+ });
634
+
635
+ test("reduced motion keeps pulse and shimmer at their base tones", () => {
636
+ const colors = tuiTheme(theme);
637
+ expect(pulseGlyphFrame(colors, "●", 600, { reducedMotion: true })).toBe(colors.fg("text.muted", "●"));
638
+ expect(shimmerFrame(colors, "working", 600, { reducedMotion: true })).toBe(colors.fg("text.secondary", "working"));
639
+ expect(rainbowShimmerFrame(colors, "working", 600, { reducedMotion: true })).toBe(
640
+ colors.fg("text.secondary", "working"),
641
+ );
642
+ expect(rainbowGlowShimmerFrame(colors, "working", 600, { reducedMotion: true })).toBe(
643
+ colors.fg("text.secondary", "working"),
644
+ );
645
+ expect(lightningShimmerFrame(colors, "working", 600, { reducedMotion: true })).toBe(
646
+ colors.fg("text.secondary", "working"),
647
+ );
648
+ });
649
+ });