@underactive/pi-topping 0.3.1 → 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 +59 -17
- package/package.json +5 -5
- package/src/activity-meter.ts +11 -8
- package/src/format.ts +159 -47
- package/src/menu.ts +84 -23
- package/src/preview.ts +115 -32
- package/src/prompt-decorator.ts +85 -10
- package/src/session.ts +184 -58
- package/src/settings.ts +168 -38
package/README.md
CHANGED
|
@@ -2,60 +2,102 @@
|
|
|
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, 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
|
-
|
|
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
|
|
30
|
+
║ [■] Border style ‹ double › ║
|
|
31
|
+
║ [■] Border color ‹ borderAccent › ║
|
|
30
32
|
║ [■] Pi icon ON ║
|
|
31
33
|
║ [■] Timestamp ON ║
|
|
34
|
+
║ [■] Provider ON ║
|
|
35
|
+
║ [■] Model ON ║
|
|
32
36
|
║ ║
|
|
33
|
-
╟─ Working Loader
|
|
37
|
+
╟─ “Working” Loader ─────────────────────────────────────────╢
|
|
34
38
|
║ [■] Animated spinner ON ║
|
|
35
|
-
║
|
|
36
|
-
║ [■] Randomize
|
|
39
|
+
║ ❯ [■] Animated spinner color ‹ accent › ║
|
|
40
|
+
║ [■] Randomize “Working” text ON ║
|
|
37
41
|
║ [■] Text shimmer ON ║
|
|
42
|
+
║ [■] Invert shimmer OFF ║
|
|
38
43
|
║ [■] Text shimmer direction ‹ Left to Right › ║
|
|
44
|
+
║ [■] Text shimmer speed ‹ Normal › ║
|
|
39
45
|
║ [■] Token activity monitor ON ║
|
|
40
46
|
║ [■] Token activity monitor color ‹ accent › ║
|
|
41
|
-
║ [■] Token activity monitor direction ‹
|
|
47
|
+
║ [■] Token activity monitor direction ‹ Right to Left › ║
|
|
42
48
|
║ [■] Token activity monitor dimmed OFF ║
|
|
43
49
|
║ [■] Elapsed time since prompt ON ║
|
|
44
50
|
║ [■] Show output tokens ON ║
|
|
51
|
+
║ [■] Token rate ON ║
|
|
52
|
+
║ [■] Token rate color ‹ warning › ║
|
|
53
|
+
║ [■] Token rate dimmed OFF ║
|
|
54
|
+
║ ║
|
|
55
|
+
╟─ Elements Order ───────────────────────────────────────────╢
|
|
56
|
+
║ [ ] Animated spinner ║
|
|
57
|
+
║ [ ] “Working” text ║
|
|
58
|
+
║ [ ] Token activity monitor ║
|
|
59
|
+
║ [ ] Token rate ║
|
|
60
|
+
║ [ ] Elapsed time ║
|
|
61
|
+
║ [ ] Output tokens ║
|
|
45
62
|
║ ║
|
|
46
63
|
╟─ Completion Marker ────────────────────────────────────────╢
|
|
47
64
|
║ [■] Show completion marker ON ║
|
|
65
|
+
║ [■] Border style ‹ none › ║
|
|
66
|
+
║ [■] Border color ‹ borderAccent › ║
|
|
48
67
|
║ [■] Pi icon ON ║
|
|
49
|
-
║ [■] Randomize
|
|
68
|
+
║ [■] Randomize “Worked” text ON ║
|
|
50
69
|
║ [■] Tokens spent ON ║
|
|
70
|
+
║ [■] Mid-turn inputs ON ║
|
|
51
71
|
║ ║
|
|
52
72
|
╟─ Options ──────────────────────────────────────────────────╢
|
|
53
73
|
║ [■] Use NerdFont icons ON ║
|
|
54
74
|
╟────────────────────────────────────────────────────────────╢
|
|
55
75
|
║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
|
|
56
|
-
|
|
76
|
+
╚════════════════════════════════════════════════════[ 9/37 ]╝
|
|
57
77
|
```
|
|
58
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
|
+
|
|
89
|
+
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
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.
|
|
100
|
+
|
|
59
101
|
## Install
|
|
60
102
|
|
|
61
103
|
```bash
|
|
@@ -77,7 +119,7 @@ npm test
|
|
|
77
119
|
npm run typecheck
|
|
78
120
|
```
|
|
79
121
|
|
|
80
|
-
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.
|
|
81
123
|
|
|
82
124
|
## Limitations
|
|
83
125
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
4
|
-
|
|
3
|
+
"version": "0.5.0",
|
|
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",
|
|
7
7
|
"author": "Eric Sison",
|
|
@@ -30,13 +30,13 @@
|
|
|
30
30
|
"@earendil-works/pi-tui": "*"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
34
|
-
"@earendil-works/pi-tui": "0.
|
|
33
|
+
"@earendil-works/pi-coding-agent": "0.82.1",
|
|
34
|
+
"@earendil-works/pi-tui": "0.82.1",
|
|
35
35
|
"@types/node": "^24.0.0",
|
|
36
36
|
"typescript": "^5.9.3"
|
|
37
37
|
},
|
|
38
38
|
"pi": {
|
|
39
39
|
"extensions": ["./index.ts"],
|
|
40
|
-
"
|
|
40
|
+
"image": "https://raw.githubusercontent.com/underactive/pi-topping/main/media/working.png"
|
|
41
41
|
}
|
|
42
42
|
}
|
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,19 +1,44 @@
|
|
|
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
5
5
|
|
|
6
|
+
/** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
|
|
7
|
+
export const SPINNER_FRAME_MS = 80;
|
|
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
|
+
|
|
21
|
+
/** A user-orderable piece of the working indicator. */
|
|
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"];
|
|
26
|
+
|
|
27
|
+
/** Elements share a detail group; separators and non-rate details are dimmed. */
|
|
28
|
+
const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate"]);
|
|
29
|
+
|
|
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;
|
|
37
|
+
|
|
6
38
|
/** Format an output-token count for the working indicator. */
|
|
7
39
|
export function formatTokens(count: number): string {
|
|
8
40
|
if (count < 1000) return count.toString();
|
|
9
|
-
const
|
|
10
|
-
{ threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
|
|
11
|
-
{ threshold: 999_500, divisor: 1_000, decimals: 0, suffix: "k" },
|
|
12
|
-
{ threshold: 10_000_000, divisor: 1_000_000, decimals: 1, suffix: "M" },
|
|
13
|
-
{ threshold: 1_000_000_000, divisor: 1_000_000, decimals: 0, suffix: "M" },
|
|
14
|
-
{ threshold: 1e15, divisor: 1_000_000_000, decimals: 1, suffix: "B" },
|
|
15
|
-
];
|
|
16
|
-
const unit = UNITS.find(u => count < u.threshold);
|
|
41
|
+
const unit = TOKEN_UNITS.find(u => count < u.threshold);
|
|
17
42
|
if (unit) {
|
|
18
43
|
const value = unit.decimals > 0
|
|
19
44
|
? (count / unit.divisor).toFixed(unit.decimals)
|
|
@@ -23,6 +48,50 @@ export function formatTokens(count: number): string {
|
|
|
23
48
|
return `${(count / 1_000_000_000).toFixed(1)}B`;
|
|
24
49
|
}
|
|
25
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
|
+
|
|
26
95
|
/** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
|
|
27
96
|
export function formatElapsed(ms: number): string {
|
|
28
97
|
let totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
@@ -43,67 +112,110 @@ export function formatElapsed(ms: number): string {
|
|
|
43
112
|
return parts.join(" ");
|
|
44
113
|
}
|
|
45
114
|
|
|
46
|
-
/**
|
|
115
|
+
/** True when every appearance toggle is off and Pi's stock loader can be restored. */
|
|
47
116
|
export function isFullyDefaultAppearance(features: {
|
|
48
117
|
substituteDefaultMessage: boolean;
|
|
49
118
|
elapsedTime: boolean;
|
|
50
119
|
outputTokens: boolean;
|
|
120
|
+
tokenRate: boolean;
|
|
51
121
|
}, decorations: { shimmer: boolean; tokenActivityMonitor: boolean }): boolean {
|
|
52
|
-
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;
|
|
53
123
|
}
|
|
54
124
|
|
|
55
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* Assemble the working indicator from already-styled pieces, laid out in `order`.
|
|
127
|
+
*
|
|
128
|
+
* Empty/omitted pieces are dropped, and any run of detail elements left adjacent
|
|
129
|
+
* after that renders as one visual group with dim separators.
|
|
130
|
+
*/
|
|
56
131
|
export function buildWorkingMessage(
|
|
57
132
|
theme: Pick<Theme, "fg">,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
elapsed: string,
|
|
61
|
-
tokens: string,
|
|
62
|
-
features: { elapsedTime: boolean; outputTokens: boolean },
|
|
133
|
+
parts: Partial<Record<LoaderElement, string>>,
|
|
134
|
+
order: readonly LoaderElement[] = DEFAULT_LOADER_ORDER,
|
|
63
135
|
): string {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
136
|
+
const segments: string[] = [];
|
|
137
|
+
let details: string[] = [];
|
|
138
|
+
const flushDetails = (): void => {
|
|
139
|
+
if (details.length) segments.push(details.join(theme.fg("dim", " · ")));
|
|
140
|
+
details = [];
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (const element of order) {
|
|
144
|
+
const value = parts[element];
|
|
145
|
+
if (!value) continue;
|
|
146
|
+
if (DETAIL_ELEMENTS.has(element)) {
|
|
147
|
+
// tokenRate arrives pre-colored; dimming it here would replace its configured color.
|
|
148
|
+
details.push(element === "tokenRate" ? value : theme.fg("dim", value));
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
flushDetails();
|
|
152
|
+
segments.push(value);
|
|
153
|
+
}
|
|
154
|
+
flushDetails();
|
|
155
|
+
|
|
156
|
+
return segments.join(" ");
|
|
68
157
|
}
|
|
69
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
|
+
*/
|
|
70
169
|
export function shimmerString(
|
|
71
170
|
text: string,
|
|
72
171
|
elapsedMs: number,
|
|
73
|
-
theme: Pick<Theme, "getFgAnsi">,
|
|
172
|
+
theme: Pick<Theme, "getFgAnsi" | "fg">,
|
|
74
173
|
direction: "ltr" | "rtl" = "ltr",
|
|
174
|
+
speed: "slow" | "normal" | "fast" = "normal",
|
|
175
|
+
invert = false,
|
|
75
176
|
): string {
|
|
76
177
|
const chars = [...text];
|
|
77
178
|
if (chars.length === 0) return "";
|
|
78
|
-
const
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
const SHIMMER_BASE = ansiToRgb(theme.getFgAnsi("dim"));
|
|
82
|
-
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);
|
|
83
182
|
const period = chars.length + SHIMMER_PADDING * 2;
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
183
|
+
const unitsPerS = period / SHIMMER_SWEEP_S;
|
|
184
|
+
// The band crosses padding at either end while every character is still dim. Scaling only
|
|
185
|
+
// the stretch where it actually overlaps the text keeps that dark pause identical at every
|
|
186
|
+
// speed, so `speed` changes the sweep alone rather than the whole cycle.
|
|
187
|
+
const litEnter = SHIMMER_PADDING - SHIMMER_BAND_HALF;
|
|
188
|
+
const litExit = SHIMMER_PADDING + chars.length - 1 + SHIMMER_BAND_HALF;
|
|
189
|
+
const enterS = litEnter / unitsPerS;
|
|
190
|
+
const litS = (litExit - litEnter) / unitsPerS / (speed === "slow" ? 0.5 : speed === "fast" ? 2 : 1);
|
|
191
|
+
const phase = (elapsedMs / 1000) % (enterS + litS + (period - litExit) / unitsPerS);
|
|
192
|
+
const linear = phase < enterS
|
|
193
|
+
? phase * unitsPerS
|
|
194
|
+
: phase < enterS + litS
|
|
195
|
+
? litEnter + ((phase - enterS) / litS) * (litExit - litEnter)
|
|
196
|
+
: litExit + (phase - enterS - litS) * unitsPerS;
|
|
197
|
+
const pos = direction === "rtl" ? period - linear : linear;
|
|
198
|
+
|
|
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";
|
|
102
214
|
}
|
|
103
215
|
|
|
104
|
-
function ansiToRgb(ansi: string): [number, number, number] {
|
|
216
|
+
function ansiToRgb(ansi: string): [number, number, number] | null {
|
|
105
217
|
const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
|
|
106
|
-
if (!match)
|
|
218
|
+
if (!match) return null;
|
|
107
219
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
108
220
|
}
|
|
109
221
|
|