pum-agent 0.2.25-beta.1 → 0.2.27-beta.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pum-agent",
3
- "version": "0.2.25-beta.1",
3
+ "version": "0.2.27-beta.1",
4
4
  "description": "A compact terminal coding agent powered by pi and OpenTUI.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/animation.tsx CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  type MarkdownRenderable,
6
6
  type RGBA,
7
7
  type TextChunk,
8
+ type TextareaRenderable,
8
9
  type TextRenderable,
9
10
  } from "@opentui/core";
10
11
  import { useRenderer } from "@opentui/react";
@@ -49,7 +50,7 @@ const SHIMMER_WIDTH = 7;
49
50
  const SHIMMER_TAIL = 120;
50
51
 
51
52
  const CARET = "▊";
52
- export const CARET_PERIOD_MS = 900;
53
+ export const CARET_PERIOD_MS = 1_800;
53
54
  /** Ramp length at each end of the caret's cycle, as a share of the period. */
54
55
  const CARET_RAMP = 0.12;
55
56
  /** Quantised fade steps, so one ramp costs a bounded number of repaints. */
@@ -138,7 +139,7 @@ export function weightedGlyph(glyph: string, strength: number): string {
138
139
  return glyph === "\u2500" && strength >= HEAVY_RULE_STRENGTH ? "\u2501" : glyph;
139
140
  }
140
141
 
141
- const RULE_CHARS_PER_MS = 0.08;
142
+ const RULE_CHARS_PER_MS = 0.04;
142
143
  const RULE_HIGHLIGHT_WIDTH = 10;
143
144
  const COMET_CHARS_PER_MS = 0.035;
144
145
  const ELECTRIC_FRAME_MS = 140;
@@ -221,6 +222,48 @@ const ClockContext = createContext<Clock>({
221
222
 
222
223
  export const useClock = () => useContext(ClockContext);
223
224
 
225
+ /** The prompt cursor stays visible for most of each slow blink cycle. */
226
+ export function inputCursorVisible(elapsedMs: number): boolean {
227
+ const phase = ((elapsedMs % CARET_PERIOD_MS) + CARET_PERIOD_MS) % CARET_PERIOD_MS;
228
+ return phase < CARET_PERIOD_MS * 0.65;
229
+ }
230
+
231
+ /** Replace the terminal-defined fast cursor blink with the shared slow clock. */
232
+ export function PromptCursorBlink({
233
+ inputRef,
234
+ active,
235
+ }: {
236
+ inputRef: RefObject<TextareaRenderable | null>;
237
+ active: boolean;
238
+ }) {
239
+ const { subscribe, enabled } = useClock();
240
+
241
+ useEffect(() => {
242
+ const input = inputRef.current;
243
+ if (!input) return;
244
+ input.cursorStyle = { style: "block", blinking: false };
245
+ if (!active) {
246
+ input.showCursor = false;
247
+ return;
248
+ }
249
+
250
+ input.showCursor = true;
251
+ if (!enabled) return;
252
+
253
+ let startedAt: number | undefined;
254
+ let visible = true;
255
+ return subscribe((elapsedMs) => {
256
+ startedAt ??= elapsedMs;
257
+ const next = inputCursorVisible(elapsedMs - startedAt);
258
+ if (next === visible) return;
259
+ visible = next;
260
+ if (inputRef.current) inputRef.current.showCursor = next;
261
+ });
262
+ }, [active, enabled, inputRef, subscribe]);
263
+
264
+ return null;
265
+ }
266
+
224
267
  /**
225
268
  * One frame callback for the whole app. Animated components write straight to
226
269
  * their own renderable, so no React render happens per frame. The renderer is
@@ -324,6 +367,140 @@ export function shimmer(text: string, base: RGBA, hi: RGBA, elapsedMs: number):
324
367
  return new StyledText(chunks);
325
368
  }
326
369
 
370
+ /**
371
+ * True raised forms, and only those.
372
+ *
373
+ * A terminal cell grid has no half-row offset, so a letter cannot be drawn
374
+ * between two rows. These modifier letters are the same letter cut higher in
375
+ * the cell, which is as close as the grid allows. The map holds no lookalike
376
+ * substitutes: a letter with no raised form of its own — uppercase F, digits,
377
+ * punctuation — is left exactly as it is rather than replaced by a glyph that
378
+ * reads as a different character.
379
+ */
380
+ const RAISED_LETTERS: Record<string, string> = {
381
+ a: "ᵃ", b: "ᵇ", c: "ᶜ", d: "ᵈ", e: "ᵉ", f: "ᶠ", g: "ᵍ", h: "ʰ", i: "ⁱ", j: "ʲ",
382
+ k: "ᵏ", l: "ˡ", m: "ᵐ", n: "ⁿ", o: "ᵒ", p: "ᵖ", r: "ʳ", s: "ˢ", t: "ᵗ", u: "ᵘ",
383
+ v: "ᵛ", w: "ʷ", x: "ˣ", y: "ʸ", z: "ᶻ",
384
+ A: "ᴬ", B: "ᴮ", D: "ᴰ", E: "ᴱ", G: "ᴳ", H: "ᴴ", I: "ᴵ", J: "ᴶ", K: "ᴷ", L: "ᴸ",
385
+ M: "ᴹ", N: "ᴺ", O: "ᴼ", P: "ᴾ", R: "ᴿ", T: "ᵀ", U: "ᵁ", V: "ⱽ", W: "ᵂ",
386
+ };
387
+
388
+ /** How long one crest takes to cross the phrase, and the rest between sweeps. */
389
+ const WAVE_CHARS_PER_MS = 0.011;
390
+ const WAVE_WIDTH = 5;
391
+ const WAVE_REST_CHARS = 90;
392
+
393
+ /**
394
+ * One bright crest travelling across the first `crestEnd` characters, with a
395
+ * long rest between sweeps. The remainder — the steer hint — never moves, so
396
+ * it keeps reading as a fixed note rather than as part of the motion.
397
+ */
398
+ export function placeholderWave(
399
+ text: string,
400
+ crestEnd: number,
401
+ base: RGBA,
402
+ hi: RGBA,
403
+ elapsedMs: number,
404
+ lift = true,
405
+ ): StyledText {
406
+ const characters = Array.from(text);
407
+ const crest = Math.max(0, Math.min(crestEnd, characters.length));
408
+ const bloom = bloomColor(hi);
409
+ const period = crest + WAVE_REST_CHARS;
410
+ const head = ((elapsedMs * WAVE_CHARS_PER_MS) % period) - WAVE_WIDTH;
411
+ // Exactly one letter rides the top of the crest. A strength threshold would
412
+ // lift two or three at once, or none at all between cells.
413
+ const peak = Math.round(head);
414
+ const chunks: TextChunk[] = [];
415
+ let runColor: RGBA | null = null;
416
+ let runText = "";
417
+ const flush = () => {
418
+ if (runColor && runText) chunks.push(fg(runColor)(runText));
419
+ runText = "";
420
+ };
421
+
422
+ for (let index = 0; index < crest; index++) {
423
+ const strength = glowFalloff(index - head, WAVE_WIDTH);
424
+ const color = glowColor(base, hi, bloom, strength);
425
+ const raised = lift && index === peak ? RAISED_LETTERS[characters[index]!] : undefined;
426
+ const glyph = raised ?? characters[index]!;
427
+ if (runColor && sameColor(runColor, color) && !raised) {
428
+ runText += glyph;
429
+ continue;
430
+ }
431
+ flush();
432
+ runColor = color;
433
+ runText = glyph;
434
+ // A raised cell is one cell wide and never merges with its neighbours.
435
+ if (raised) {
436
+ flush();
437
+ runColor = null;
438
+ }
439
+ }
440
+ flush();
441
+ if (crest < characters.length) {
442
+ chunks.push(fg(base)(characters.slice(crest).join("")));
443
+ }
444
+ return new StyledText(chunks);
445
+ }
446
+
447
+ /**
448
+ * Animates a textarea placeholder in place.
449
+ *
450
+ * The textarea accepts StyledText for its placeholder, so the wave needs no
451
+ * overlay element. React keeps passing the plain string, which is what shows
452
+ * whenever the wave is inactive — with animations off, or without true colour,
453
+ * the placeholder is exactly the one PUM has always drawn.
454
+ */
455
+ export function usePlaceholderWave(opts: {
456
+ inputRef: RefObject<TextareaRenderable | null>;
457
+ text: string;
458
+ crestEnd: number;
459
+ color: string;
460
+ highlight: string;
461
+ active: boolean;
462
+ lift?: boolean;
463
+ }) {
464
+ const { inputRef, text, crestEnd, color, highlight, active, lift = true } = opts;
465
+ const { subscribe, enabled } = useClock();
466
+ const latest = useRef({ text, crestEnd });
467
+ latest.current = { text, crestEnd };
468
+
469
+ useEffect(() => {
470
+ if (!active || !enabled) {
471
+ if (inputRef.current) inputRef.current.placeholder = text;
472
+ return;
473
+ }
474
+ const base = rgba(color);
475
+ const hi = rgba(highlight);
476
+ const stop = subscribe((elapsedMs) => {
477
+ if (!inputRef.current) return;
478
+ inputRef.current.placeholder = placeholderWave(
479
+ latest.current.text,
480
+ latest.current.crestEnd,
481
+ base,
482
+ hi,
483
+ elapsedMs,
484
+ lift,
485
+ );
486
+ });
487
+ return () => {
488
+ stop();
489
+ // Hand the plain string back, or the last frame would stay frozen on it.
490
+ if (inputRef.current) inputRef.current.placeholder = latest.current.text;
491
+ };
492
+ }, [inputRef, text, active, enabled, color, highlight, lift, subscribe]);
493
+ }
494
+
495
+ /**
496
+ * The clock provider sits below App, so the wave runs from a child that draws
497
+ * nothing and only owns the placeholder of the textarea it is given.
498
+ */
499
+ export function PlaceholderWave(props: Parameters<typeof usePlaceholderWave>[0]): null {
500
+ usePlaceholderWave(props);
501
+ return null;
502
+ }
503
+
327
504
  /**
328
505
  * Owns the renderable's `content` outright — pass the returned ref to a bare
329
506
  * `<text ref={...} />` and set no `content` prop, or the two will fight.