@underactive/pi-topping 0.4.0 → 0.5.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.
- package/README.md +45 -18
- package/package.json +1 -1
- package/src/activity-meter.ts +11 -8
- package/src/format.ts +110 -41
- package/src/menu.ts +15 -17
- package/src/preview.ts +107 -37
- package/src/prompt-decorator.ts +76 -12
- package/src/session.ts +150 -61
- package/src/settings.ts +104 -38
package/README.md
CHANGED
|
@@ -2,60 +2,70 @@
|
|
|
2
2
|
|
|
3
3
|
We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is a [Pi](https://github.com/earendil-works/pi-coding-agent) extension that adds some quality of life improvements to Pi's UX, while looking good in the process:
|
|
4
4
|
|
|
5
|
-
**User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, and
|
|
5
|
+
**User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, timestamp, and provider/model label.
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
**“Working” Loader** — animated spinner (with color choice), randomized activity word, shimmer (with direction and
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, shimmer (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, and live output-token rate (with color choice and dim toggle) — all arrangeable left to right.
|
|
10
10
|
|
|
11
|
-

|
|
12
12
|
|
|
13
|
-
**Completion Marker** — end-of-turn marker with icon, randomized verb,
|
|
13
|
+
**Completion Marker** — end-of-turn marker with icon, randomized verb, token consumption display, and optional border-style/color decorations. Border style defaults to `none`; `heavy` renders e.g. `┗━━ π Mustered for 4s (↓ 25 tokens) ━━━━━━ ━━━━ ━━ ━`. If you steered or followed up (`Alt+Enter`) while Pi was working, the marker also tallies those, e.g. `π Whisked for 2s (↓ 55 tokens · 2 mid-turn inputs)`. Hooks into Pi's `agent_settled` event.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
17
|
## Settings
|
|
18
18
|
|
|
19
|
-
Run `/topping-settings` (TUI only) to customize your toppings
|
|
19
|
+
Run `/topping-settings` (TUI only) to customize your toppings. Settings persist to
|
|
20
|
+
`~/.pi/agent/pi-topping/settings.json`; hand edits are tolerated on load.
|
|
20
21
|
|
|
21
22
|
```
|
|
22
|
-
|
|
23
|
+
╔═[ Pi Topping: Settings ]═══════════════════════════════════╗
|
|
23
24
|
╟─ Preview ──────────────────────────────────────────────────╢
|
|
24
25
|
║ ║
|
|
25
|
-
║
|
|
26
|
+
║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tok/s · 3s · ↓ 84 tokens ║
|
|
26
27
|
║ ║
|
|
27
28
|
╟─ User Prompt ──────────────────────────────────────────────╢
|
|
28
29
|
║ [■] High-vis prompt ON ║
|
|
29
|
-
║ [■] Border color ‹ accent › ║
|
|
30
30
|
║ [■] Border style ‹ double › ║
|
|
31
|
+
║ [■] Border color ‹ borderAccent › ║
|
|
31
32
|
║ [■] Pi icon ON ║
|
|
32
33
|
║ [■] Timestamp ON ║
|
|
34
|
+
║ [■] Provider ON ║
|
|
35
|
+
║ [■] Model ON ║
|
|
33
36
|
║ ║
|
|
34
37
|
╟─ “Working” Loader ─────────────────────────────────────────╢
|
|
35
38
|
║ [■] Animated spinner ON ║
|
|
36
|
-
║
|
|
37
|
-
║ [■] Randomize
|
|
39
|
+
║ ❯ [■] Animated spinner color ‹ accent › ║
|
|
40
|
+
║ [■] Randomize “Working” text ON ║
|
|
38
41
|
║ [■] Text shimmer ON ║
|
|
42
|
+
║ [■] Invert shimmer OFF ║
|
|
39
43
|
║ [■] Text shimmer direction ‹ Left to Right › ║
|
|
40
44
|
║ [■] Text shimmer speed ‹ Normal › ║
|
|
41
45
|
║ [■] Token activity monitor ON ║
|
|
42
46
|
║ [■] Token activity monitor color ‹ accent › ║
|
|
43
|
-
║ [■] Token activity monitor direction ‹
|
|
47
|
+
║ [■] Token activity monitor direction ‹ Right to Left › ║
|
|
44
48
|
║ [■] Token activity monitor dimmed OFF ║
|
|
45
49
|
║ [■] Elapsed time since prompt ON ║
|
|
46
50
|
║ [■] Show output tokens ON ║
|
|
51
|
+
║ [■] Token rate ON ║
|
|
52
|
+
║ [■] Token rate color ‹ warning › ║
|
|
53
|
+
║ [■] Token rate dimmed OFF ║
|
|
47
54
|
║ ║
|
|
48
55
|
╟─ Elements Order ───────────────────────────────────────────╢
|
|
49
56
|
║ [ ] Animated spinner ║
|
|
50
|
-
║ [
|
|
57
|
+
║ [ ] “Working” text ║
|
|
51
58
|
║ [ ] Token activity monitor ║
|
|
59
|
+
║ [ ] Token rate ║
|
|
52
60
|
║ [ ] Elapsed time ║
|
|
53
61
|
║ [ ] Output tokens ║
|
|
54
62
|
║ ║
|
|
55
63
|
╟─ Completion Marker ────────────────────────────────────────╢
|
|
56
64
|
║ [■] Show completion marker ON ║
|
|
65
|
+
║ [■] Border style ‹ none › ║
|
|
66
|
+
║ [■] Border color ‹ borderAccent › ║
|
|
57
67
|
║ [■] Pi icon ON ║
|
|
58
|
-
║ [■] Randomize
|
|
68
|
+
║ [■] Randomize “Worked” text ON ║
|
|
59
69
|
║ [■] Tokens spent ON ║
|
|
60
70
|
║ [■] Mid-turn inputs ON ║
|
|
61
71
|
║ ║
|
|
@@ -63,13 +73,30 @@ Run `/topping-settings` (TUI only) to customize your toppings:
|
|
|
63
73
|
║ [■] Use NerdFont icons ON ║
|
|
64
74
|
╟────────────────────────────────────────────────────────────╢
|
|
65
75
|
║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
|
|
66
|
-
|
|
76
|
+
╚════════════════════════════════════════════════════[ 9/37 ]╝
|
|
67
77
|
```
|
|
68
78
|
|
|
79
|
+
❯ marks the keyboard cursor, and the selected row is highlighted. The Provider and Model toggles independently control the lower-right label on decorated prompts.
|
|
80
|
+
|
|
81
|
+
The User Prompt Border style cycles through `double`, `single`, `rounded`, and `heavy` box-drawing sets; the Border color row sits directly beneath it. The Completion Marker Border style offers those same choices plus `none`; `none` leaves the marker undecorated, and its Border color row sits directly beneath it.
|
|
82
|
+
|
|
83
|
+
A cycle row's `[■]` box gates it: `␣` unchecks the box, snaps the setting back to its default, and stops `←`/`→` from cycling. The Completion Marker Border style and Border color rows, and Token rate color, have no gate — their box stays `[■]` and `␣` does nothing.
|
|
84
|
+
|
|
85
|
+
The User Prompt and Completion Marker Border color settings, along with the Animated spinner, Token activity monitor, and Token rate colors, cycle through `accent`, `border`, `borderAccent`, `success`, `error`, and `warning`.
|
|
86
|
+
|
|
87
|
+
Invert shimmer keeps the working text at the default text color and sweeps a dimmed gradient across it.
|
|
88
|
+
|
|
69
89
|
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
70
|
-
left or right within the loader. Elapsed time
|
|
71
|
-
|
|
72
|
-
|
|
90
|
+
left or right within the loader. Elapsed time, output tokens, and token rate are joined with
|
|
91
|
+
`·` separators whenever adjacent. When reordering separates them, each adjacent run is rendered
|
|
92
|
+
without a separator before or after it.
|
|
93
|
+
|
|
94
|
+
The `N tok/s` token rate uses the selected theme color and
|
|
95
|
+
remains reorderable. Active rates below 1,000 are padded to three characters so updates do not shift the other
|
|
96
|
+
segments; larger rates may shift later segments. When inactive, it remains as the dim `--- tok/s` placeholder. After its last update it holds
|
|
97
|
+
full brightness for 1.5 seconds, then fades through five theme-aware shades to the dim text color over
|
|
98
|
+
the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
|
|
99
|
+
restarts the cycle.
|
|
73
100
|
|
|
74
101
|
## Install
|
|
75
102
|
|
|
@@ -92,7 +119,7 @@ npm test
|
|
|
92
119
|
npm run typecheck
|
|
93
120
|
```
|
|
94
121
|
|
|
95
|
-
Tests cover the word/shimmer rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component, and the settings menu wiring end to end.
|
|
122
|
+
Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component, and the settings menu wiring end to end.
|
|
96
123
|
|
|
97
124
|
## Limitations
|
|
98
125
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
|
|
5
5
|
"keywords": ["pi-package", "pi-extension"],
|
|
6
6
|
"license": "MIT",
|
package/src/activity-meter.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { dimAttribute } from "./format.ts";
|
|
2
3
|
|
|
3
4
|
export const ActivityMeterLevel = {
|
|
4
5
|
IDLE: 0,
|
|
@@ -25,15 +26,15 @@ const BRAILLE: Record<ActivityMeterLevel, string> = {
|
|
|
25
26
|
[ActivityMeterLevel.FULL]: "⣿",
|
|
26
27
|
};
|
|
27
28
|
const WIDTH = 8;
|
|
29
|
+
const RATE_THRESHOLDS = [0, 5, 10, 15, 22, 30, 40] as const;
|
|
28
30
|
type CellColorizer = (level: ActivityMeterLevel, char: string) => string;
|
|
29
31
|
|
|
30
32
|
/** Convert an estimated output-token rate to a display level. */
|
|
31
33
|
export function rateToLevel(tokensPerSecond: number): ActivityMeterLevel {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (tokensPerSecond > THRESHOLDS[i]) return (i + 1) as ActivityMeterLevel;
|
|
34
|
+
for (let i = RATE_THRESHOLDS.length - 1; i >= 0; i--) {
|
|
35
|
+
if (tokensPerSecond > RATE_THRESHOLDS[i]) return (i + 1) as ActivityMeterLevel;
|
|
35
36
|
}
|
|
36
|
-
return
|
|
37
|
+
return ActivityMeterLevel.IDLE;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
/** EMA-smoothed rate tracker for a cumulative output-token estimate. */
|
|
@@ -44,6 +45,11 @@ export class TokRateTracker {
|
|
|
44
45
|
#hasSample = false;
|
|
45
46
|
#pendingTokens = 0;
|
|
46
47
|
|
|
48
|
+
/** Latest EMA-smoothed output-token rate. */
|
|
49
|
+
get tokenRate(): number {
|
|
50
|
+
return this.#rate;
|
|
51
|
+
}
|
|
52
|
+
|
|
47
53
|
sample(totalTokens: number, now: number): number {
|
|
48
54
|
if (!this.#hasSample) {
|
|
49
55
|
this.#lastTotal = totalTokens;
|
|
@@ -127,9 +133,6 @@ export class ActivityMeter {
|
|
|
127
133
|
): string {
|
|
128
134
|
if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
|
|
129
135
|
const colored = theme.fg(color, char);
|
|
130
|
-
|
|
131
|
-
// This is necessary because theme.fg("dim", ...) sets a gray color instead
|
|
132
|
-
// of applying the ANSI dim attribute, so nesting would overwrite the accent color.
|
|
133
|
-
return dimmed ? `\x1b[2m${colored}\x1b[22m` : colored;
|
|
136
|
+
return dimmed ? dimAttribute(colored) : colored;
|
|
134
137
|
}
|
|
135
138
|
}
|
package/src/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
/** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
|
|
4
4
|
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -6,26 +6,39 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
|
|
|
6
6
|
/** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
|
|
7
7
|
export const SPINNER_FRAME_MS = 80;
|
|
8
8
|
|
|
9
|
+
/** Default "Working…" text shown when text randomization/substitution is off. */
|
|
10
|
+
export const DEFAULT_WORKING_WORD = "Working…";
|
|
11
|
+
|
|
12
|
+
/** Milliseconds between activity meter sample pushes. */
|
|
13
|
+
export const METER_INTERVAL_MS = 100;
|
|
14
|
+
|
|
15
|
+
/** Milliseconds between shimmer re-renders. */
|
|
16
|
+
export const SHIMMER_INTERVAL_MS = 50;
|
|
17
|
+
|
|
18
|
+
/** Milliseconds between elapsed-time display updates. */
|
|
19
|
+
export const ELAPSED_INTERVAL_MS = 1_000;
|
|
20
|
+
|
|
9
21
|
/** A user-orderable piece of the working indicator. */
|
|
10
|
-
export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens";
|
|
22
|
+
export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens" | "tokenRate";
|
|
23
|
+
|
|
24
|
+
/** Default first-use working-indicator element order. */
|
|
25
|
+
export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens"];
|
|
11
26
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
27
|
+
/** Elements share a detail group; separators and non-rate details are dimmed. */
|
|
28
|
+
const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate"]);
|
|
14
29
|
|
|
15
|
-
|
|
16
|
-
|
|
30
|
+
const TOKEN_UNITS = [
|
|
31
|
+
{ threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
|
|
32
|
+
{ threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
|
|
33
|
+
{ threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
|
|
34
|
+
{ threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
|
|
35
|
+
{ threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
|
|
36
|
+
] as const;
|
|
17
37
|
|
|
18
38
|
/** Format an output-token count for the working indicator. */
|
|
19
39
|
export function formatTokens(count: number): string {
|
|
20
40
|
if (count < 1000) return count.toString();
|
|
21
|
-
const
|
|
22
|
-
{ threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
|
|
23
|
-
{ threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
|
|
24
|
-
{ threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
|
|
25
|
-
{ threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
|
|
26
|
-
{ threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
|
|
27
|
-
];
|
|
28
|
-
const unit = UNITS.find(u => count < u.threshold);
|
|
41
|
+
const unit = TOKEN_UNITS.find(u => count < u.threshold);
|
|
29
42
|
if (unit) {
|
|
30
43
|
const value = unit.decimals > 0
|
|
31
44
|
? (count / unit.divisor).toFixed(unit.decimals)
|
|
@@ -35,6 +48,50 @@ export function formatTokens(count: number): string {
|
|
|
35
48
|
return `${(count / 1_000_000_000).toFixed(1)}B`;
|
|
36
49
|
}
|
|
37
50
|
|
|
51
|
+
/** Column width reserved for the numeric portion of the token-rate readout. */
|
|
52
|
+
const TOKEN_RATE_DIGITS = 3;
|
|
53
|
+
|
|
54
|
+
/** Placeholder shown when the working indicator has no active token-rate sample. */
|
|
55
|
+
export const TOKEN_RATE_PLACEHOLDER = `${"-".repeat(TOKEN_RATE_DIGITS)} tok/s`;
|
|
56
|
+
|
|
57
|
+
/** Format an output-token throughput estimate for the working indicator. */
|
|
58
|
+
export function formatTokenRate(rate: number): string {
|
|
59
|
+
const rounded = Math.round(rate);
|
|
60
|
+
return rounded === 0 ? "" : `${rounded.toString().padStart(TOKEN_RATE_DIGITS)} tok/s`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Wrap text in ANSI dim (SGR 2 / reset 22).
|
|
65
|
+
*
|
|
66
|
+
* theme.fg("dim", ...) sets a gray color instead of the ANSI dim attribute, so it
|
|
67
|
+
* would overwrite an already-applied accent color rather than darkening it; the raw
|
|
68
|
+
* SGR codes are needed to dim on top of an existing color.
|
|
69
|
+
*/
|
|
70
|
+
export function dimAttribute(text: string): string {
|
|
71
|
+
return `\x1b[2m${text}\x1b[22m`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Number of discrete, eased warning-to-dim shades used for the token-rate fade. */
|
|
75
|
+
export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
|
|
76
|
+
|
|
77
|
+
/** Render one token-rate fade shade by blending the selected color toward the active theme's dim color. */
|
|
78
|
+
export function fadeThemeColorString(
|
|
79
|
+
text: string,
|
|
80
|
+
shade: number,
|
|
81
|
+
theme: Pick<Theme, "getFgAnsi" | "fg">,
|
|
82
|
+
color: ThemeColor,
|
|
83
|
+
): string {
|
|
84
|
+
if (!text) return "";
|
|
85
|
+
const source = ansiToRgb(theme.getFgAnsi(color));
|
|
86
|
+
const dim = ansiToRgb(theme.getFgAnsi("dim"));
|
|
87
|
+
if (!source || !dim) return theme.fg(color, text);
|
|
88
|
+
const clampedShade = Math.max(0, Math.min(TOKEN_RATE_FADE_SHADE_COUNT - 1, Math.floor(shade)));
|
|
89
|
+
const progress = (clampedShade + 1) / TOKEN_RATE_FADE_SHADE_COUNT;
|
|
90
|
+
const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
|
|
91
|
+
const blended = source.map((channel, index) => Math.round(channel * (1 - eased) + dim[index]! * eased));
|
|
92
|
+
return `\x1b[38;2;${blended[0]};${blended[1]};${blended[2]}m${text}\x1b[0m`;
|
|
93
|
+
}
|
|
94
|
+
|
|
38
95
|
/** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
|
|
39
96
|
export function formatElapsed(ms: number): string {
|
|
40
97
|
let totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
@@ -55,20 +112,21 @@ export function formatElapsed(ms: number): string {
|
|
|
55
112
|
return parts.join(" ");
|
|
56
113
|
}
|
|
57
114
|
|
|
58
|
-
/**
|
|
115
|
+
/** True when every appearance toggle is off and Pi's stock loader can be restored. */
|
|
59
116
|
export function isFullyDefaultAppearance(features: {
|
|
60
117
|
substituteDefaultMessage: boolean;
|
|
61
118
|
elapsedTime: boolean;
|
|
62
119
|
outputTokens: boolean;
|
|
120
|
+
tokenRate: boolean;
|
|
63
121
|
}, decorations: { shimmer: boolean; tokenActivityMonitor: boolean }): boolean {
|
|
64
|
-
return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens;
|
|
122
|
+
return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens && !features.tokenRate;
|
|
65
123
|
}
|
|
66
124
|
|
|
67
125
|
/**
|
|
68
126
|
* Assemble the working indicator from already-styled pieces, laid out in `order`.
|
|
69
127
|
*
|
|
70
128
|
* Empty/omitted pieces are dropped, and any run of detail elements left adjacent
|
|
71
|
-
* after that
|
|
129
|
+
* after that renders as one visual group with dim separators.
|
|
72
130
|
*/
|
|
73
131
|
export function buildWorkingMessage(
|
|
74
132
|
theme: Pick<Theme, "fg">,
|
|
@@ -78,7 +136,7 @@ export function buildWorkingMessage(
|
|
|
78
136
|
const segments: string[] = [];
|
|
79
137
|
let details: string[] = [];
|
|
80
138
|
const flushDetails = (): void => {
|
|
81
|
-
if (details.length) segments.push(theme.fg("dim",
|
|
139
|
+
if (details.length) segments.push(details.join(theme.fg("dim", " · ")));
|
|
82
140
|
details = [];
|
|
83
141
|
};
|
|
84
142
|
|
|
@@ -86,7 +144,8 @@ export function buildWorkingMessage(
|
|
|
86
144
|
const value = parts[element];
|
|
87
145
|
if (!value) continue;
|
|
88
146
|
if (DETAIL_ELEMENTS.has(element)) {
|
|
89
|
-
|
|
147
|
+
// tokenRate arrives pre-colored; dimming it here would replace its configured color.
|
|
148
|
+
details.push(element === "tokenRate" ? value : theme.fg("dim", value));
|
|
90
149
|
continue;
|
|
91
150
|
}
|
|
92
151
|
flushDetails();
|
|
@@ -97,20 +156,29 @@ export function buildWorkingMessage(
|
|
|
97
156
|
return segments.join(" ");
|
|
98
157
|
}
|
|
99
158
|
|
|
159
|
+
const SHIMMER_SWEEP_S = 2.0;
|
|
160
|
+
const SHIMMER_BAND_HALF = 5.0;
|
|
161
|
+
const SHIMMER_PADDING = 10;
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Codex-style light-sweep shimmer using the active Pi theme.
|
|
165
|
+
*
|
|
166
|
+
* With `invert=true`, the text color is used at rest and the sweep moves toward
|
|
167
|
+
* the theme's dim color.
|
|
168
|
+
*/
|
|
100
169
|
export function shimmerString(
|
|
101
170
|
text: string,
|
|
102
171
|
elapsedMs: number,
|
|
103
|
-
theme: Pick<Theme, "getFgAnsi">,
|
|
172
|
+
theme: Pick<Theme, "getFgAnsi" | "fg">,
|
|
104
173
|
direction: "ltr" | "rtl" = "ltr",
|
|
105
174
|
speed: "slow" | "normal" | "fast" = "normal",
|
|
175
|
+
invert = false,
|
|
106
176
|
): string {
|
|
107
177
|
const chars = [...text];
|
|
108
178
|
if (chars.length === 0) return "";
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
const SHIMMER_BASE = ansiToRgb(theme.getFgAnsi("dim"));
|
|
113
|
-
const SHIMMER_HIGHLIGHT = ansiToRgb(theme.getFgAnsi("text"));
|
|
179
|
+
const shimmerBase = ansiToRgb(theme.getFgAnsi(invert ? "text" : "dim"));
|
|
180
|
+
const shimmerHighlight = ansiToRgb(theme.getFgAnsi(invert ? "dim" : "text"));
|
|
181
|
+
if (!shimmerBase || !shimmerHighlight) return theme.fg("text", text);
|
|
114
182
|
const period = chars.length + SHIMMER_PADDING * 2;
|
|
115
183
|
const unitsPerS = period / SHIMMER_SWEEP_S;
|
|
116
184
|
// The band crosses padding at either end while every character is still dim. Scaling only
|
|
@@ -128,25 +196,26 @@ export function shimmerString(
|
|
|
128
196
|
: litExit + (phase - enterS - litS) * unitsPerS;
|
|
129
197
|
const pos = direction === "rtl" ? period - linear : linear;
|
|
130
198
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
}
|
|
144
|
-
|
|
199
|
+
let out = "";
|
|
200
|
+
for (let i = 0; i < chars.length; i++) {
|
|
201
|
+
const ch = chars[i];
|
|
202
|
+
const dist = Math.abs((i + SHIMMER_PADDING) - pos);
|
|
203
|
+
const t = dist <= SHIMMER_BAND_HALF
|
|
204
|
+
? 0.5 * (1 + Math.cos(Math.PI * dist / SHIMMER_BAND_HALF))
|
|
205
|
+
: 0;
|
|
206
|
+
const alpha = t * 0.9;
|
|
207
|
+
const r = Math.round(shimmerHighlight[0] * alpha + shimmerBase[0] * (1 - alpha));
|
|
208
|
+
const g = Math.round(shimmerHighlight[1] * alpha + shimmerBase[1] * (1 - alpha));
|
|
209
|
+
const b = Math.round(shimmerHighlight[2] * alpha + shimmerBase[2] * (1 - alpha));
|
|
210
|
+
const bold = !invert && t > 0.2 ? "\x1b[1m" : "";
|
|
211
|
+
out += `${bold}\x1b[38;2;${r};${g};${b}m${ch}\x1b[22m`;
|
|
212
|
+
}
|
|
213
|
+
return out + "\x1b[0m";
|
|
145
214
|
}
|
|
146
215
|
|
|
147
|
-
function ansiToRgb(ansi: string): [number, number, number] {
|
|
216
|
+
function ansiToRgb(ansi: string): [number, number, number] | null {
|
|
148
217
|
const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
|
|
149
|
-
if (!match)
|
|
218
|
+
if (!match) return null;
|
|
150
219
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
151
220
|
}
|
|
152
221
|
|
package/src/menu.ts
CHANGED
|
@@ -10,12 +10,12 @@
|
|
|
10
10
|
* preview section driven by a caller-supplied render callback, e.g.:
|
|
11
11
|
*
|
|
12
12
|
* ╔═[ Pi Topping: Settings ]════════════════════════╗
|
|
13
|
-
* ╟─ Preview
|
|
13
|
+
* ╟─ Preview ────────────────────────────────────╢
|
|
14
14
|
* ║ ║
|
|
15
|
-
* ║ ⠋ Cerebrating… ⣤⣤⣤⣤
|
|
15
|
+
* ║ ⠋ Cerebrating… ⣤⣤⣤⣤ 28 tok/s · 3s · ↓ 84 tokens ║
|
|
16
16
|
* ║ ║
|
|
17
17
|
* ╟─ Decorations ───────────────────────────────────╢
|
|
18
|
-
* ║
|
|
18
|
+
* ║ ❯ [■] Animated spinner ON ║
|
|
19
19
|
* ║ [ ] "Working..." text shimmer OFF ║
|
|
20
20
|
* ║ ║
|
|
21
21
|
* ╟──────────────────────────────────────────────────╢
|
|
@@ -90,6 +90,7 @@ export interface MenuResult<T> {
|
|
|
90
90
|
|
|
91
91
|
const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
92
92
|
const MAX_WIDTH = 76;
|
|
93
|
+
const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
|
|
93
94
|
|
|
94
95
|
interface FlatItem {
|
|
95
96
|
id: string;
|
|
@@ -442,7 +443,7 @@ export class MenuComponent implements Component {
|
|
|
442
443
|
const prefix = titlePrefix ?? "";
|
|
443
444
|
const suffix = titleSuffix ?? "";
|
|
444
445
|
const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
|
|
445
|
-
const shownTitle = title
|
|
446
|
+
const shownTitle = visibleWidth(title) > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
|
|
446
447
|
const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
|
|
447
448
|
const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
|
|
448
449
|
const titleContent = th.fg("text", styledTitle);
|
|
@@ -493,45 +494,42 @@ export class MenuComponent implements Component {
|
|
|
493
494
|
private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
|
|
494
495
|
const th = this.theme;
|
|
495
496
|
const value = this.values[item.id]!;
|
|
496
|
-
const marker = selected ? "\
|
|
497
|
+
const marker = selected ? "\u276f" : " ";
|
|
497
498
|
const markerColored = selected ? th.fg("accent", marker) : marker;
|
|
498
499
|
|
|
499
500
|
if (item.reorderGroup) {
|
|
500
501
|
const held = value as boolean;
|
|
501
502
|
const stateWord = held ? "\u2191 \u2193" : "";
|
|
502
503
|
const rightPlain = `${stateWord} `;
|
|
503
|
-
const
|
|
504
|
-
const
|
|
505
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
504
|
+
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
|
|
505
|
+
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
506
506
|
const leftPlain = ` ${marker} [${held ? "\u25a0" : " "}] ${label}`;
|
|
507
507
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
508
508
|
const content = ` ${markerColored} [${held ? th.fg("accent", "\u25a0") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${held ? th.fg("accent", stateWord) : ""} `;
|
|
509
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
509
|
+
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
510
510
|
}
|
|
511
511
|
|
|
512
512
|
if (item.cycleValues) {
|
|
513
513
|
const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
|
|
514
514
|
const stateWord = `‹ ${value} ›`;
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
515
|
+
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
|
|
516
|
+
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
518
517
|
const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
|
|
519
518
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
|
|
520
519
|
const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
|
|
521
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
520
|
+
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
522
521
|
}
|
|
523
522
|
|
|
524
523
|
const enabled = value as boolean;
|
|
525
524
|
const box = enabled ? "\u25a0" : " ";
|
|
526
525
|
const stateWord = enabled ? "ON" : "OFF";
|
|
527
526
|
const rightPlain = `${stateWord} `;
|
|
528
|
-
const
|
|
529
|
-
const
|
|
530
|
-
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
527
|
+
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(rightPlain) - 1);
|
|
528
|
+
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
531
529
|
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
532
530
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
533
531
|
const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
|
|
534
|
-
return this.wrap("\u2551", content, "\u2551");
|
|
532
|
+
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
535
533
|
}
|
|
536
534
|
}
|
|
537
535
|
|