pum-agent 0.1.0-beta.3

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.
@@ -0,0 +1,476 @@
1
+ import {
2
+ StyledText,
3
+ fg,
4
+ type MarkdownRenderable,
5
+ type RGBA,
6
+ type TextChunk,
7
+ type TextRenderable,
8
+ } from "@opentui/core";
9
+ import { useRenderer } from "@opentui/react";
10
+ import {
11
+ createContext,
12
+ useCallback,
13
+ useContext,
14
+ useEffect,
15
+ useRef,
16
+ type ReactNode,
17
+ type RefObject,
18
+ } from "react";
19
+ import { mix, rgba } from "./theme";
20
+ import type { WorkingRuleAnimationMode } from "./settings";
21
+
22
+ /** A colour sweep quantised to 256 colours reads as flicker, not motion. */
23
+ export function supportsTrueColor(): boolean {
24
+ const v = process.env.COLORTERM;
25
+ return v === "truecolor" || v === "24bit";
26
+ }
27
+
28
+ export const PULSE = "▁▂▃▄▅▆▇▆▅▄▃▂".split("");
29
+ const PULSE_MS = 70;
30
+
31
+ // "Brisk": the shimmer head travels ~28 characters a second. Motion is derived
32
+ // from elapsed time, so it looks the same whatever the renderer's frame rate is.
33
+ const SHIMMER_CHARS_PER_MS = 0.028;
34
+ const SHIMMER_WIDTH = 7;
35
+ /** Longest run the sweep covers, so a growing answer costs a fixed amount. */
36
+ const SHIMMER_TAIL = 120;
37
+
38
+ const CARET = "▊";
39
+ /**
40
+ * Same display width as the caret, so blinking never changes layout.
41
+ * Braille blank is not whitespace, so it cannot turn a partial `#` into a
42
+ * Markdown heading when the caret blinks off.
43
+ */
44
+ export const CARET_PLACEHOLDER = "\u2800";
45
+ const CARET_PERIOD_MS = 900;
46
+
47
+ const RULE_CHARS_PER_MS = 0.08;
48
+ const RULE_HIGHLIGHT_WIDTH = 10;
49
+
50
+ export type WorkingRuleRole = "headerTop" | "headerBottom" | "inputTop" | "inputBottom";
51
+ export type CoordinatedRuleState = {
52
+ head: number;
53
+ direction: 1 | -1;
54
+ pair: "input" | "header";
55
+ };
56
+
57
+ const isInputRule = (role: WorkingRuleRole) => role === "inputTop" || role === "inputBottom";
58
+
59
+ /**
60
+ * Route one wave around the two rule pairs. Each half-cycle has enough time for
61
+ * the head to traverse the full terminal width at the original rule speed.
62
+ */
63
+ export function coordinatedRuleState(
64
+ width: number,
65
+ elapsedMs: number,
66
+ cycleWidth = width,
67
+ ): CoordinatedRuleState {
68
+ const safeWidth = Math.max(1, width);
69
+ const safeCycleWidth = Math.max(1, cycleWidth);
70
+ const halfCycleMs = Math.max(1, safeCycleWidth - 1) / RULE_CHARS_PER_MS;
71
+ const cycleElapsed = ((elapsedMs % (halfCycleMs * 2)) + halfCycleMs * 2) % (halfCycleMs * 2);
72
+ const headerPhase = cycleElapsed >= halfCycleMs;
73
+ const halfElapsed = headerPhase ? cycleElapsed - halfCycleMs : cycleElapsed;
74
+ const progress = Math.min(1, halfElapsed / halfCycleMs);
75
+ const distance = (safeWidth - 1) * progress;
76
+
77
+ return headerPhase
78
+ ? { head: safeWidth - 1 - distance, direction: -1, pair: "header" }
79
+ : { head: distance, direction: 1, pair: "input" };
80
+ }
81
+
82
+ /** Return the frame state for a visible rule, or null when that rule is static. */
83
+ export function workingRuleFrameState(
84
+ mode: WorkingRuleAnimationMode,
85
+ role: WorkingRuleRole,
86
+ width: number,
87
+ elapsedMs: number,
88
+ cycleWidth = width,
89
+ ): CoordinatedRuleState | null {
90
+ if (mode === "off" || width <= 0) return null;
91
+ if (mode === "input-only") {
92
+ if (!isInputRule(role)) return null;
93
+ return {
94
+ head: (elapsedMs * RULE_CHARS_PER_MS) % Math.max(1, width),
95
+ direction: 1,
96
+ pair: "input",
97
+ };
98
+ }
99
+
100
+ const state = coordinatedRuleState(width, elapsedMs, cycleWidth);
101
+ return (state.pair === "input") === isInputRule(role) ? state : null;
102
+ }
103
+
104
+ type Subscriber = (elapsedMs: number) => void;
105
+
106
+ type Clock = {
107
+ subscribe: (cb: Subscriber) => () => void;
108
+ workingElapsed: () => number;
109
+ workingRuleCycleWidth: () => number;
110
+ enabled: boolean;
111
+ };
112
+
113
+ const ClockContext = createContext<Clock>({
114
+ subscribe: () => () => {},
115
+ workingElapsed: () => 0,
116
+ workingRuleCycleWidth: () => 1,
117
+ enabled: false,
118
+ });
119
+
120
+ export const useClock = () => useContext(ClockContext);
121
+
122
+ /**
123
+ * One frame callback for the whole app. Animated components write straight to
124
+ * their own renderable, so no React render happens per frame. The renderer is
125
+ * on-demand, so the clock holds it live only while something is animating.
126
+ */
127
+ export function AnimationProvider({
128
+ enabled,
129
+ working = false,
130
+ workingRuleWidth = 1,
131
+ children,
132
+ }: {
133
+ enabled: boolean;
134
+ working?: boolean;
135
+ workingRuleWidth?: number;
136
+ children: ReactNode;
137
+ }) {
138
+ const renderer = useRenderer();
139
+ const subs = useRef(new Set<Subscriber>());
140
+ const elapsed = useRef(0);
141
+ const workingStartedAt = useRef(0);
142
+ const workingCycleWidth = useRef(1);
143
+ const live = useRef(false);
144
+
145
+ useEffect(() => {
146
+ if (working) {
147
+ workingStartedAt.current = elapsed.current;
148
+ workingCycleWidth.current = Math.max(1, workingRuleWidth);
149
+ }
150
+ }, [working]);
151
+
152
+ useEffect(() => {
153
+ const onFrame = async (dt: number) => {
154
+ if (subs.current.size === 0) return;
155
+ elapsed.current += dt;
156
+ for (const cb of subs.current) cb(elapsed.current);
157
+ };
158
+ renderer.setFrameCallback(onFrame);
159
+ return () => renderer.removeFrameCallback(onFrame);
160
+ }, [renderer]);
161
+
162
+ const subscribe = useCallback(
163
+ (cb: Subscriber) => {
164
+ subs.current.add(cb);
165
+ if (!live.current) {
166
+ live.current = true;
167
+ renderer.requestLive();
168
+ }
169
+ return () => {
170
+ subs.current.delete(cb);
171
+ if (subs.current.size === 0 && live.current) {
172
+ live.current = false;
173
+ renderer.dropLive();
174
+ }
175
+ };
176
+ },
177
+ [renderer],
178
+ );
179
+
180
+ const workingElapsed = useCallback(
181
+ () => Math.max(0, elapsed.current - workingStartedAt.current),
182
+ [],
183
+ );
184
+ const workingRuleCycleWidth = useCallback(() => workingCycleWidth.current, []);
185
+
186
+ return (
187
+ <ClockContext.Provider
188
+ value={{ subscribe, workingElapsed, workingRuleCycleWidth, enabled }}
189
+ >
190
+ {children}
191
+ </ClockContext.Provider>
192
+ );
193
+ }
194
+
195
+ function shimmer(text: string, base: RGBA, hi: RGBA, elapsedMs: number): StyledText {
196
+ const start = Math.max(0, text.length - SHIMMER_TAIL);
197
+ const tail = text.slice(start);
198
+ const chunks = [];
199
+ if (start > 0) chunks.push(fg(base)(text.slice(0, start)));
200
+
201
+ // The head runs past the end before wrapping, so there is a beat between sweeps.
202
+ const period = tail.length + SHIMMER_WIDTH * 3;
203
+ const head = ((elapsedMs * SHIMMER_CHARS_PER_MS) % period) - SHIMMER_WIDTH;
204
+ for (let i = 0; i < tail.length; i++) {
205
+ const d = Math.abs(i - head);
206
+ const w = d < SHIMMER_WIDTH ? 1 - d / SHIMMER_WIDTH : 0;
207
+ chunks.push(fg(w > 0 ? mix(base, hi, w * w) : base)(tail[i]!));
208
+ }
209
+ return new StyledText(chunks);
210
+ }
211
+
212
+ /**
213
+ * Owns the renderable's `content` outright — pass the returned ref to a bare
214
+ * `<text ref={...} />` and set no `content` prop, or the two will fight.
215
+ *
216
+ * The caret rides inside the same styled text rather than sitting in its own
217
+ * element, so it follows the last character even when the text wraps.
218
+ */
219
+ export function useShimmerText(opts: {
220
+ text: string;
221
+ color: string;
222
+ highlight: string;
223
+ active: boolean;
224
+ caret?: boolean;
225
+ }) {
226
+ const { text, color, highlight, active, caret = false } = opts;
227
+ const ref = useRef<TextRenderable>(null);
228
+ const { subscribe, enabled } = useClock();
229
+ const latest = useRef(text);
230
+ latest.current = text;
231
+
232
+ const plain = useCallback(() => {
233
+ if (!ref.current) return;
234
+ const chunks = [fg(color)(latest.current)];
235
+ if (caret) chunks.push(fg(color)(CARET));
236
+ ref.current.content = new StyledText(chunks);
237
+ }, [color, caret]);
238
+
239
+ useEffect(() => {
240
+ if (!active || !enabled) {
241
+ plain();
242
+ return;
243
+ }
244
+ const base = rgba(color);
245
+ const hi = rgba(highlight);
246
+ const stop = subscribe((elapsedMs) => {
247
+ if (!ref.current) return;
248
+ const styled = shimmer(latest.current, base, hi, elapsedMs);
249
+ if (caret) {
250
+ const visible = elapsedMs % CARET_PERIOD_MS < CARET_PERIOD_MS * 0.6;
251
+ styled.chunks.push(fg(visible ? hi : base)(visible ? CARET : CARET_PLACEHOLDER));
252
+ }
253
+ ref.current.content = styled;
254
+ });
255
+ return () => {
256
+ stop();
257
+ plain();
258
+ };
259
+ }, [active, enabled, color, highlight, caret, subscribe, plain]);
260
+
261
+ // Repaint when the text changes but nothing is animating.
262
+ useEffect(() => {
263
+ if (!active || !enabled) plain();
264
+ }, [text, active, enabled, plain]);
265
+
266
+ return ref;
267
+ }
268
+
269
+ /** Own styled text and append a width-stable blinking caret while active. */
270
+ export function useBlinkingText(opts: {
271
+ chunks: TextChunk[];
272
+ contentKey: string;
273
+ caretColor: string;
274
+ active: boolean;
275
+ }): RefObject<TextRenderable | null> {
276
+ const { chunks, contentKey, caretColor, active } = opts;
277
+ const ref = useRef<TextRenderable>(null);
278
+ const { subscribe, enabled } = useClock();
279
+ const latest = useRef(chunks);
280
+ const caretVisible = useRef(true);
281
+ latest.current = chunks;
282
+
283
+ const paint = useCallback(() => {
284
+ if (!ref.current) return;
285
+ const caret = caretVisible.current ? CARET : CARET_PLACEHOLDER;
286
+ ref.current.content = new StyledText([...latest.current, fg(caretColor)(caret)]);
287
+ }, [caretColor]);
288
+
289
+ useEffect(() => {
290
+ if (!active) return;
291
+ if (!enabled) {
292
+ caretVisible.current = true;
293
+ paint();
294
+ return;
295
+ }
296
+
297
+ let lastVisible = caretVisible.current;
298
+ const stop = subscribe((elapsedMs) => {
299
+ const nextVisible = elapsedMs % CARET_PERIOD_MS < CARET_PERIOD_MS * 0.6;
300
+ if (nextVisible === lastVisible) return;
301
+ lastVisible = nextVisible;
302
+ caretVisible.current = nextVisible;
303
+ paint();
304
+ });
305
+ paint();
306
+ return stop;
307
+ }, [active, enabled, paint, subscribe]);
308
+
309
+ useEffect(() => {
310
+ if (active) paint();
311
+ }, [contentKey, active, paint]);
312
+
313
+ return ref;
314
+ }
315
+
316
+ /** Keep both caret frames equivalent to the Markdown parser. */
317
+ export function markdownCaretContent(text: string, visible: boolean): string {
318
+ return text + (visible ? CARET : CARET_PLACEHOLDER);
319
+ }
320
+
321
+ /** Keep a blinking caret at the end of incrementally rendered Markdown. */
322
+ export function useMarkdownCaret(
323
+ text: string,
324
+ active: boolean,
325
+ ): RefObject<MarkdownRenderable | null> {
326
+ const ref = useRef<MarkdownRenderable>(null);
327
+ const { subscribe, enabled } = useClock();
328
+ const latest = useRef(text);
329
+ const caretVisible = useRef(true);
330
+ latest.current = text;
331
+
332
+ const paint = useCallback(() => {
333
+ if (ref.current) {
334
+ ref.current.content = markdownCaretContent(latest.current, caretVisible.current);
335
+ }
336
+ }, []);
337
+
338
+ useEffect(() => {
339
+ if (!active) return;
340
+ if (!enabled) {
341
+ caretVisible.current = true;
342
+ paint();
343
+ return;
344
+ }
345
+
346
+ let lastVisible = caretVisible.current;
347
+ const stop = subscribe((elapsedMs) => {
348
+ const nextVisible = elapsedMs % CARET_PERIOD_MS < CARET_PERIOD_MS * 0.6;
349
+ if (nextVisible === lastVisible) return;
350
+ lastVisible = nextVisible;
351
+ caretVisible.current = nextVisible;
352
+ paint();
353
+ });
354
+ paint();
355
+ return stop;
356
+ }, [active, enabled, paint, subscribe]);
357
+
358
+ useEffect(() => {
359
+ if (active) paint();
360
+ }, [text, active, paint]);
361
+
362
+ return ref;
363
+ }
364
+
365
+ function ruleText(width: number, base: RGBA, hi: RGBA, head: number): StyledText {
366
+ const chunks = [];
367
+ for (let i = 0; i < width; i++) {
368
+ const distance = Math.abs(i - head);
369
+ const strength = distance < RULE_HIGHLIGHT_WIDTH
370
+ ? 1 - distance / RULE_HIGHLIGHT_WIDTH
371
+ : 0;
372
+ chunks.push(fg(strength > 0 ? mix(base, hi, strength * 0.8) : base)("─"));
373
+ }
374
+ return new StyledText(chunks);
375
+ }
376
+
377
+ /** Preserve the original input-rule sweep, including its wrapped highlight tail. */
378
+ function inputRuleText(width: number, base: RGBA, hi: RGBA, head: number): StyledText {
379
+ const chunks = [];
380
+ for (let i = 0; i < width; i++) {
381
+ const clockwise = (head - i + width) % width;
382
+ const counterclockwise = (i - head + width) % width;
383
+ const distance = Math.min(clockwise, counterclockwise);
384
+ const strength = distance < RULE_HIGHLIGHT_WIDTH
385
+ ? 1 - distance / RULE_HIGHLIGHT_WIDTH
386
+ : 0;
387
+ chunks.push(fg(strength > 0 ? mix(base, hi, strength * 0.8) : base)("─"));
388
+ }
389
+ return new StyledText(chunks);
390
+ }
391
+
392
+ /** Paint one rule from the shared frame clock without per-frame React state. */
393
+ export function useWorkingRule(opts: {
394
+ width: number;
395
+ color: string;
396
+ highlight: string;
397
+ active: boolean;
398
+ mode: WorkingRuleAnimationMode;
399
+ role: WorkingRuleRole;
400
+ }): RefObject<TextRenderable | null> {
401
+ const { width, color, highlight, active, mode, role } = opts;
402
+ const ref = useRef<TextRenderable>(null);
403
+ const { subscribe, workingElapsed, workingRuleCycleWidth, enabled } = useClock();
404
+
405
+ const plain = useCallback(() => {
406
+ if (ref.current) ref.current.content = new StyledText([fg(color)("─".repeat(width))]);
407
+ }, [color, width]);
408
+
409
+ useEffect(() => {
410
+ const canAnimate = mode === "coordinated" || (mode === "input-only" && isInputRule(role));
411
+ if (!active || !enabled || width <= 0 || !canAnimate) {
412
+ plain();
413
+ return;
414
+ }
415
+
416
+ const base = rgba(color);
417
+ const hi = rgba(highlight);
418
+ return subscribe(() => {
419
+ if (!ref.current) return;
420
+ const state = workingRuleFrameState(
421
+ mode,
422
+ role,
423
+ width,
424
+ workingElapsed(),
425
+ workingRuleCycleWidth(),
426
+ );
427
+ if (!state) {
428
+ ref.current.content = new StyledText([fg(color)("─".repeat(width))]);
429
+ return;
430
+ }
431
+ ref.current.content = mode === "input-only"
432
+ ? inputRuleText(width, base, hi, state.head)
433
+ : ruleText(width, base, hi, state.head);
434
+ });
435
+ }, [
436
+ active,
437
+ enabled,
438
+ mode,
439
+ role,
440
+ width,
441
+ color,
442
+ highlight,
443
+ plain,
444
+ subscribe,
445
+ workingElapsed,
446
+ workingRuleCycleWidth,
447
+ ]);
448
+
449
+ useEffect(() => {
450
+ if (!active || !enabled || mode === "off") plain();
451
+ }, [width, color, active, enabled, mode, plain]);
452
+
453
+ return ref;
454
+ }
455
+
456
+ /** Pulse-bar frame, or a static dot when animation is off. */
457
+ export function useSpinner(active: boolean): RefObject<TextRenderable | null> {
458
+ const ref = useRef<TextRenderable>(null);
459
+ const { subscribe, enabled } = useClock();
460
+
461
+ useEffect(() => {
462
+ if (!active) {
463
+ if (ref.current) ref.current.content = " ";
464
+ return;
465
+ }
466
+ if (!enabled) {
467
+ if (ref.current) ref.current.content = "•";
468
+ return;
469
+ }
470
+ return subscribe((elapsedMs) => {
471
+ if (ref.current) ref.current.content = PULSE[Math.floor(elapsedMs / PULSE_MS) % PULSE.length]!;
472
+ });
473
+ }, [active, enabled, subscribe]);
474
+
475
+ return ref;
476
+ }