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 +1 -1
- package/src/animation.tsx +179 -2
- package/src/app.tsx +343 -40
- package/src/bash-output.ts +4 -3
- package/src/commands.ts +26 -0
- package/src/help-popup.tsx +2 -1
- package/src/output-minimal.ts +24 -17
- package/src/providers-autocomplete.ts +59 -0
- package/src/providers-command.ts +77 -0
- package/src/providers-controller.ts +222 -0
- package/src/providers-flow.ts +100 -0
- package/src/providers-popup.tsx +192 -0
- package/src/settings-autocomplete.ts +118 -0
- package/src/settings-command.ts +459 -0
- package/src/transcript-output.ts +3 -2
package/package.json
CHANGED
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 =
|
|
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.
|
|
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.
|