@underactive/pi-topping 0.11.1 → 0.13.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 +8 -11
- package/package.json +7 -4
- package/src/format.ts +10 -201
- package/src/loader-message.ts +7 -5
- package/src/nvidia-green.ts +16 -13
- package/src/output-style.ts +0 -35
- package/src/pi-installer.ts +39 -56
- package/src/preview.ts +29 -12
- package/src/prompt-decorator.ts +40 -25
- package/src/session.ts +135 -141
- package/src/settings.ts +89 -83
- package/src/setup-check.ts +1 -1
- package/src/setup-command.ts +9 -4
- package/src/util.ts +29 -7
- package/src/activity-meter.ts +0 -147
- package/src/menu.ts +0 -697
- package/src/routesense-feed.ts +0 -49
- package/src/statusline-embed.ts +0 -12
package/README.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
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, timestamp, and provider/model label. The box, timestamp, and provider/model label
|
|
5
|
+
**User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, timestamp, and provider/model label. The box, timestamp, and provider/model label do not change the prompt Pi sends to the model, so they do not affect LLM prompt caching. Explicit `/skill:name` invocations also get a display-only box while Pi expands the skill natively; Pi's `[skill]` entry remains visible beside it. Prompts submitted while Pi is working (steer / `Alt+Enter` follow-up) are left undecorated so Pi's native `Steering:` / `Follow-up:` queue rows still render.
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection. Under a Pi virtual model such as pi-topping-routesense's `routesense/auto`, the response model is the routed physical model. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
-
**Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. It can append the response model when it differs from the selected model, with its own color option. It can also append per-call service-tier counts (e.g. `flex tier ×3`), but only for calls whose provider reports a tier
|
|
13
|
+
**Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. It can append the response model when it differs from the selected model, with its own color option. It can also append per-call service-tier counts (e.g. `flex tier ×3`), but only for calls whose provider reports a tier. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event. A cancelled run (for example, one stopped with Escape) shows `Interrupted` instead of the random verb, with the word and any border in the warning color; time, tokens, and other enabled details still appear. **Show completion marker** also controls this marker.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -128,7 +128,7 @@ While a non-default style is active, each prompt also carries a one-line hidden
|
|
|
128
128
|
|
|
129
129
|
Output styles also apply in print and RPC modes. Set `"outputStyle": "explanatory"` (or `"simplified"`) in `~/.pi/agent/pi-topping/settings.json`; if you edit the file while an interactive session is running, use `/reload` to load the change. Switching styles changes one system-prompt section. Pi appends that change to the conversation, so providers that support mid-conversation prompt updates keep their cached prefix; other providers may re-read the full prompt once.
|
|
130
130
|
|
|
131
|
-
The Explanatory prompt is copied word for word from Claude Code's output styles, with two Pi clarifications: a lead-in that frames the instructions for Pi, and an exception allowing Insight blocks despite Pi's conciseness rule. The Simplified Technical English prompt adapts the ASD-STE100 writing rules but does not include the STE dictionary of approved words. A more specific instruction from you, your project instructions, a skill, or the file being edited takes precedence over it.
|
|
131
|
+
The Explanatory prompt is copied word for word from Claude Code's output styles, with two Pi clarifications: a lead-in that frames the instructions for Pi, and an exception allowing Insight blocks despite Pi's conciseness rule. The Simplified Technical English prompt adapts the ASD-STE100 writing rules but does not include the STE dictionary of approved words. A more specific instruction from you, your project instructions, a skill, or the file being edited takes precedence over it. If `pi-topping-insights` is still installed, remove it to avoid duplicate hidden reminders; its saved style is no longer read. `/insights` is gone; use `/topping-settings`.
|
|
132
132
|
|
|
133
133
|
### Word packs
|
|
134
134
|
|
|
@@ -167,14 +167,12 @@ restarts the cycle.
|
|
|
167
167
|
|
|
168
168
|
The token activity monitor reaches full `⣿` at or above its configurable upper bound (80 tps by default), with lower levels scaled proportionally to that bound. In `/topping-settings`, `←`/`→` cycle through presets spanning 10–1000 tps, and `␣` (Space) lets you enter any whole number from 10–1000. Invalid typed entries show an inline error and keep the editor open without changing the current value; invalid hand-edited `meterUpperBoundTps` values in `settings.json` revert to 80 on load.
|
|
169
169
|
|
|
170
|
-
The response model is captured from assistant responses and raw Responses-API stream events
|
|
170
|
+
The response model is captured from assistant responses and raw Responses-API stream events, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. When Ramp Router reports a gateway backend in the raw event, the label can include it as `<model> via <backend>`, or `via <backend>` alone when the served model restates the selection. Under a Pi virtual model such as pi-topping-routesense's `routesense/auto`, it is the routed physical model: taken from the router's `pi-topping-routesense:routed-model` announcement when present, which arrives before the first token (announcements for `direct` routes such as compaction are ignored), and otherwise from the assistant message's model. There only a failed route, which reports the virtual model itself, is hidden, and a bare routed id never displaces a provider-reported decorated form of itself such as a dated name. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed, as is the selected model's id or display name written with other case and separators; trailing parenthesized notes in the display name, such as `(Local)`, are ignored. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
|
|
171
171
|
|
|
172
172
|
## Requirements
|
|
173
173
|
|
|
174
|
-
- [Pi](https://github.com/earendil-works/pi-coding-agent)
|
|
175
|
-
-
|
|
176
|
-
- Pi ≥1.1.0 shows `Interrupted` for cancelled runs; older versions show the normal completion marker.
|
|
177
|
-
- On Pi ≥0.85.0, the working indicator is embedded in the default editor border and long loaders are truncated to the border width instead of wrapping; custom editors that do not opt in keep the standalone wrapping indicator.
|
|
174
|
+
- [Pi](https://github.com/earendil-works/pi-coding-agent) ≥1.2.0. Output styles rely on Pi running the agent-start hook for the run that the decorated prompt starts, which Pi 1.2.0 added. On an older host pi-topping shows a one-time warning at session start, and output styles do not reach decorated prompts.
|
|
175
|
+
- The working indicator is embedded in the default editor border and long loaders are truncated to the border width instead of wrapping; custom editors that do not opt in keep the standalone wrapping indicator.
|
|
178
176
|
- Node.js ≥22.19.0
|
|
179
177
|
|
|
180
178
|
## Development
|
|
@@ -192,8 +190,7 @@ Tests cover the word/shimmer rendering, prompt box and completion marker renderi
|
|
|
192
190
|
- Token counts are a streaming word-count estimate, not an exact tokenizer count — they self-correct when each message finishes, so you'll see a small jump at that point.
|
|
193
191
|
- Elapsed time normally starts when you submit a prompt; for auto-continuations without that event, it starts slightly later instead.
|
|
194
192
|
- If another extension also customizes the working message, whichever one runs last wins.
|
|
195
|
-
- Service-tier details and Responses-API served-model/backend labels depend on Pi's `provider_stream_event`;
|
|
196
|
-
- Output styles have no effect on hosts without structured system-prompt sections (see Requirements).
|
|
193
|
+
- Service-tier details and Responses-API served-model/backend labels depend on Pi's `provider_stream_event`; SDK adapters that do not expose raw events show neither.
|
|
197
194
|
|
|
198
195
|
## Author
|
|
199
196
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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, an end-of-turn completion marker, and selectable output styles",
|
|
5
5
|
"keywords": ["pi-package", "pi-extension"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": ["index.ts", "src/**/*.ts", "wordpacks/*.json", "README.md", "LICENSE"],
|
|
24
24
|
"scripts": {
|
|
25
|
-
"test": "node --test tests/*.test.ts",
|
|
25
|
+
"test": "node -C compiled --test tests/*.test.ts",
|
|
26
26
|
"typecheck": "tsc --noEmit"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
@@ -30,13 +30,16 @@
|
|
|
30
30
|
"@earendil-works/pi-tui": "*"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
34
|
-
"@earendil-works/pi-tui": "1.
|
|
33
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
34
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
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
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@underactive/pi-topping-primitives": "^0.10.0"
|
|
41
44
|
}
|
|
42
45
|
}
|
package/src/format.ts
CHANGED
|
@@ -1,33 +1,19 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { ThinkingLevelColor } from "./settings.ts";
|
|
3
2
|
|
|
4
|
-
/**
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export
|
|
3
|
+
/**
|
|
4
|
+
* Pi's default working-indicator frames (same braille spinner as pi-tui's Loader) and the
|
|
5
|
+
* milliseconds per frame, matching its cadence.
|
|
6
|
+
*/
|
|
7
|
+
export { SPINNER_FRAME_MS, SPINNER_FRAMES } from "@underactive/pi-topping-primitives/glyphs";
|
|
9
8
|
|
|
10
9
|
/** Default "Working" text shown when text randomization/substitution is off. */
|
|
11
10
|
export const DEFAULT_WORKING_WORD = "Working";
|
|
12
11
|
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
return typeof value === "string" && THINKING_LEVEL_VALUES.some(level => level === value);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Build a colorizer for a configured color, including Pi's active thinking-level color. */
|
|
22
|
-
export function getThinkingLevelColorizer(
|
|
23
|
-
theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
|
|
24
|
-
color: ThinkingLevelColor,
|
|
25
|
-
thinkingLevel: ThinkingLevel | undefined,
|
|
26
|
-
): (text: string) => string {
|
|
27
|
-
return color === "thinking-level"
|
|
28
|
-
? theme.getThinkingBorderColor(thinkingLevel ?? "off")
|
|
29
|
-
: (text: string) => theme.fg(color, text);
|
|
30
|
-
}
|
|
12
|
+
export {
|
|
13
|
+
isThinkingLevel,
|
|
14
|
+
THINKING_LEVELS as THINKING_LEVEL_VALUES,
|
|
15
|
+
type ThinkingLevel,
|
|
16
|
+
} from "@underactive/pi-topping-primitives/models";
|
|
31
17
|
|
|
32
18
|
/** Milliseconds between activity meter sample pushes. */
|
|
33
19
|
export const METER_INTERVAL_MS = 100;
|
|
@@ -85,83 +71,15 @@ export function formatTokenRate(rate: number): string {
|
|
|
85
71
|
return rounded === 0 ? "" : `${rounded.toString().padStart(TOKEN_RATE_DIGITS)} tps`;
|
|
86
72
|
}
|
|
87
73
|
|
|
88
|
-
/**
|
|
89
|
-
* Wrap text in ANSI dim (SGR 2 / reset 22).
|
|
90
|
-
*
|
|
91
|
-
* theme.fg("dim", ...) sets a gray color instead of the ANSI dim attribute, so it
|
|
92
|
-
* would overwrite an already-applied accent color rather than darkening it; the raw
|
|
93
|
-
* SGR codes are needed to dim on top of an existing color.
|
|
94
|
-
*/
|
|
95
|
-
export function dimAttribute(text: string): string {
|
|
96
|
-
return `\x1b[2m${text}\x1b[22m`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Number of discrete, eased shades used by the token-rate and response-model fades. */
|
|
100
|
-
export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
|
|
101
|
-
|
|
102
74
|
/** Milliseconds a settled response model remains fully visible before fading. */
|
|
103
75
|
export const RESPONSE_MODEL_HOLD_MS = 3_000;
|
|
104
76
|
|
|
105
77
|
/** Milliseconds over which a settled response model fades through the theme shades. */
|
|
106
78
|
export const RESPONSE_MODEL_FADE_MS = 500;
|
|
107
79
|
|
|
108
|
-
/** Milliseconds over which a newly shown response model slides out of the working loader. */
|
|
109
|
-
export const RESPONSE_MODEL_SLIDE_MS = 300;
|
|
110
|
-
|
|
111
80
|
/** Milliseconds between working-loader repaints while the response model slides out. */
|
|
112
81
|
export const RESPONSE_MODEL_SLIDE_FRAME_MS = 30;
|
|
113
82
|
|
|
114
|
-
/**
|
|
115
|
-
* The part of a sliding response model visible `elapsedMs` after it appeared. It emerges
|
|
116
|
-
* tail first from behind the separator before it, as pi-topping-statusline slides an
|
|
117
|
-
* embedded status out, and shows whole once RESPONSE_MODEL_SLIDE_MS has passed.
|
|
118
|
-
*/
|
|
119
|
-
export function slideOutTail(text: string, elapsedMs: number): string {
|
|
120
|
-
if (elapsedMs >= RESPONSE_MODEL_SLIDE_MS) return text;
|
|
121
|
-
const chars = [...text];
|
|
122
|
-
const progress = Math.max(0, elapsedMs) / RESPONSE_MODEL_SLIDE_MS;
|
|
123
|
-
const shown = Math.round(chars.length * 0.5 * (1 - Math.cos(Math.PI * progress)));
|
|
124
|
-
return chars.slice(chars.length - shown).join("");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
|
|
128
|
-
export function fadeThemeColorString(
|
|
129
|
-
text: string,
|
|
130
|
-
shade: number,
|
|
131
|
-
theme: Pick<Theme, "getFgAnsi">,
|
|
132
|
-
colorizer: (text: string) => string,
|
|
133
|
-
): string {
|
|
134
|
-
if (!text) return "";
|
|
135
|
-
const source = ansiToRgb(colorizer("x"));
|
|
136
|
-
const dim = ansiToRgb(theme.getFgAnsi("dim"));
|
|
137
|
-
if (!source || !dim) return colorizer(text);
|
|
138
|
-
const clampedShade = Math.max(0, Math.min(TOKEN_RATE_FADE_SHADE_COUNT - 1, Math.floor(shade)));
|
|
139
|
-
const progress = (clampedShade + 1) / TOKEN_RATE_FADE_SHADE_COUNT;
|
|
140
|
-
const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
|
|
141
|
-
const blended = source.map((channel, index) => Math.round(channel * (1 - eased) + dim[index]! * eased));
|
|
142
|
-
return `\x1b[38;2;${blended[0]};${blended[1]};${blended[2]}m${text}\x1b[0m`;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** Format elapsed milliseconds as a compact human-readable duration, skipping leading zero units. */
|
|
146
|
-
export function formatElapsed(ms: number): string {
|
|
147
|
-
let totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
148
|
-
const parts: string[] = [];
|
|
149
|
-
|
|
150
|
-
const days = Math.floor(totalSeconds / 86400);
|
|
151
|
-
totalSeconds -= days * 86400;
|
|
152
|
-
const hours = Math.floor(totalSeconds / 3600);
|
|
153
|
-
totalSeconds -= hours * 3600;
|
|
154
|
-
const minutes = Math.floor(totalSeconds / 60);
|
|
155
|
-
const seconds = totalSeconds % 60;
|
|
156
|
-
|
|
157
|
-
if (days > 0) parts.push(`${days}d`);
|
|
158
|
-
if (hours > 0) parts.push(`${hours}h`);
|
|
159
|
-
if (minutes > 0) parts.push(`${minutes}m`);
|
|
160
|
-
parts.push(`${seconds}s`);
|
|
161
|
-
|
|
162
|
-
return parts.join(" ");
|
|
163
|
-
}
|
|
164
|
-
|
|
165
83
|
/** True when every appearance toggle is off and Pi's stock loader can be restored. */
|
|
166
84
|
export function isFullyDefaultAppearance(features: {
|
|
167
85
|
substituteDefaultMessage: boolean;
|
|
@@ -205,112 +123,3 @@ export function buildWorkingMessage(
|
|
|
205
123
|
|
|
206
124
|
return segments.join(" ");
|
|
207
125
|
}
|
|
208
|
-
|
|
209
|
-
const SHIMMER_SWEEP_S = 2.0;
|
|
210
|
-
const SHIMMER_BAND_HALF = 5.0;
|
|
211
|
-
const SHIMMER_PADDING = 10;
|
|
212
|
-
|
|
213
|
-
/**
|
|
214
|
-
* Codex-style light-sweep shimmer using the active Pi theme.
|
|
215
|
-
*
|
|
216
|
-
* With `invert=true`, the text color is used at rest and the sweep moves toward
|
|
217
|
-
* the theme's dim color.
|
|
218
|
-
*/
|
|
219
|
-
export function shimmerString(
|
|
220
|
-
text: string,
|
|
221
|
-
elapsedMs: number,
|
|
222
|
-
theme: Pick<Theme, "getFgAnsi" | "fg">,
|
|
223
|
-
direction: "ltr" | "rtl" = "ltr",
|
|
224
|
-
speed: "slow" | "normal" | "fast" = "normal",
|
|
225
|
-
invert = false,
|
|
226
|
-
): string {
|
|
227
|
-
const chars = [...text];
|
|
228
|
-
if (chars.length === 0) return "";
|
|
229
|
-
const shimmerBase = ansiToRgb(theme.getFgAnsi(invert ? "text" : "dim"));
|
|
230
|
-
const shimmerHighlight = ansiToRgb(theme.getFgAnsi(invert ? "dim" : "text"));
|
|
231
|
-
if (!shimmerBase || !shimmerHighlight) return theme.fg("text", text);
|
|
232
|
-
const period = chars.length + SHIMMER_PADDING * 2;
|
|
233
|
-
const unitsPerS = period / SHIMMER_SWEEP_S;
|
|
234
|
-
// The band crosses padding at either end while every character is still dim. Scaling only
|
|
235
|
-
// the stretch where it actually overlaps the text keeps that dark pause identical at every
|
|
236
|
-
// speed, so `speed` changes the sweep alone rather than the whole cycle.
|
|
237
|
-
const litEnter = SHIMMER_PADDING - SHIMMER_BAND_HALF;
|
|
238
|
-
const litExit = SHIMMER_PADDING + chars.length - 1 + SHIMMER_BAND_HALF;
|
|
239
|
-
const enterS = litEnter / unitsPerS;
|
|
240
|
-
const litS = (litExit - litEnter) / unitsPerS / (speed === "slow" ? 0.5 : speed === "fast" ? 2 : 1);
|
|
241
|
-
const phase = (elapsedMs / 1000) % (enterS + litS + (period - litExit) / unitsPerS);
|
|
242
|
-
const linear = phase < enterS
|
|
243
|
-
? phase * unitsPerS
|
|
244
|
-
: phase < enterS + litS
|
|
245
|
-
? litEnter + ((phase - enterS) / litS) * (litExit - litEnter)
|
|
246
|
-
: litExit + (phase - enterS - litS) * unitsPerS;
|
|
247
|
-
const pos = direction === "rtl" ? period - linear : linear;
|
|
248
|
-
|
|
249
|
-
let out = "";
|
|
250
|
-
for (let i = 0; i < chars.length; i++) {
|
|
251
|
-
const ch = chars[i];
|
|
252
|
-
const dist = Math.abs((i + SHIMMER_PADDING) - pos);
|
|
253
|
-
const t = dist <= SHIMMER_BAND_HALF
|
|
254
|
-
? 0.5 * (1 + Math.cos(Math.PI * dist / SHIMMER_BAND_HALF))
|
|
255
|
-
: 0;
|
|
256
|
-
const alpha = t * 0.9;
|
|
257
|
-
const r = Math.round(shimmerHighlight[0] * alpha + shimmerBase[0] * (1 - alpha));
|
|
258
|
-
const g = Math.round(shimmerHighlight[1] * alpha + shimmerBase[1] * (1 - alpha));
|
|
259
|
-
const b = Math.round(shimmerHighlight[2] * alpha + shimmerBase[2] * (1 - alpha));
|
|
260
|
-
const bold = !invert && t > 0.2 ? "\x1b[1m" : "";
|
|
261
|
-
out += `${bold}\x1b[38;2;${r};${g};${b}m${ch}\x1b[22m`;
|
|
262
|
-
}
|
|
263
|
-
return out + "\x1b[0m";
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function ansiToRgb(ansi: string): [number, number, number] | null {
|
|
267
|
-
const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
|
|
268
|
-
if (!match) return null;
|
|
269
|
-
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/**
|
|
273
|
-
* Incremental whitespace-boundary word counter.
|
|
274
|
-
*
|
|
275
|
-
* The in-word state is retained across streaming chunks so a word split across
|
|
276
|
-
* multiple deltas is counted exactly once.
|
|
277
|
-
*/
|
|
278
|
-
export class StreamingWordCounter {
|
|
279
|
-
#inWordByStream = new Map<string, boolean>();
|
|
280
|
-
|
|
281
|
-
count(text: string, stream = "default"): number {
|
|
282
|
-
let inWord = this.#inWordByStream.get(stream) ?? false;
|
|
283
|
-
let count = 0;
|
|
284
|
-
for (let i = 0; i < text.length; i++) {
|
|
285
|
-
const code = text.charCodeAt(i);
|
|
286
|
-
if (isWhitespace(code)) {
|
|
287
|
-
inWord = false;
|
|
288
|
-
} else if (!inWord) {
|
|
289
|
-
count++;
|
|
290
|
-
inWord = true;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
this.#inWordByStream.set(stream, inWord);
|
|
294
|
-
return count;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
reset(): void {
|
|
298
|
-
this.#inWordByStream.clear();
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function isWhitespace(code: number): boolean {
|
|
303
|
-
return (
|
|
304
|
-
code === 32 ||
|
|
305
|
-
(code >= 9 && code <= 13) ||
|
|
306
|
-
code === 160 ||
|
|
307
|
-
code === 0x1680 ||
|
|
308
|
-
(code >= 0x2000 && code <= 0x200a) ||
|
|
309
|
-
code === 0x2028 ||
|
|
310
|
-
code === 0x2029 ||
|
|
311
|
-
code === 0x202f ||
|
|
312
|
-
code === 0x205f ||
|
|
313
|
-
code === 0x3000 ||
|
|
314
|
-
code === 0xfeff
|
|
315
|
-
);
|
|
316
|
-
}
|
package/src/loader-message.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { formatElapsedUnits } from "@underactive/pi-topping-primitives/activity";
|
|
3
|
+
import { dimAttribute, fadeThemeColorString, shimmerString, thinkingLevelColorizer } from "@underactive/pi-topping-primitives/activity-tui";
|
|
4
|
+
import { buildWorkingMessage, DEFAULT_WORKING_WORD, formatTokens, isFullyDefaultAppearance, SPINNER_FRAME_MS, SPINNER_FRAMES, TOKEN_RATE_PLACEHOLDER, type LoaderElement, type ThinkingLevel } from "./format.ts";
|
|
3
5
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
4
6
|
import type { DecoratorSettings } from "./settings.ts";
|
|
5
7
|
|
|
@@ -27,7 +29,7 @@ export function buildLoaderMessage(
|
|
|
27
29
|
loaderOrder: readonly LoaderElement[],
|
|
28
30
|
parts: LoaderParts,
|
|
29
31
|
): string {
|
|
30
|
-
const spinner = parts.spinnerAtMs === undefined ? "" :
|
|
32
|
+
const spinner = parts.spinnerAtMs === undefined ? "" : thinkingLevelColorizer(theme, decorations.spinnerColor, thinkingLevel)(SPINNER_FRAMES[Math.floor(parts.spinnerAtMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
|
|
31
33
|
const responseModelColored = parts.responseModel ? getResponseModelColorizer(theme, decorations.responseModelColor, thinkingLevel, provider)(parts.responseModel) : "";
|
|
32
34
|
const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
|
|
33
35
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
@@ -38,20 +40,20 @@ export function buildLoaderMessage(
|
|
|
38
40
|
? shimmerString(word, parts.shimmerAtMs, theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
|
|
39
41
|
: theme.fg("text", word);
|
|
40
42
|
const meter = decorations.tokenActivityMonitor && parts.renderMeter
|
|
41
|
-
? parts.renderMeter(
|
|
43
|
+
? parts.renderMeter(thinkingLevelColorizer(theme, decorations.meterColor, thinkingLevel), decorations.meterDimmed)
|
|
42
44
|
: "";
|
|
43
45
|
let tokenRate = "";
|
|
44
46
|
if (features.tokenRate) {
|
|
45
47
|
if (!parts.tokenRateText) tokenRate = theme.fg("dim", TOKEN_RATE_PLACEHOLDER);
|
|
46
48
|
else {
|
|
47
|
-
const colorizer =
|
|
49
|
+
const colorizer = thinkingLevelColorizer(theme, decorations.tokenRateColor, thinkingLevel);
|
|
48
50
|
tokenRate = parts.tokenRateShade === undefined ? colorizer(parts.tokenRateText) : fadeThemeColorString(parts.tokenRateText, parts.tokenRateShade, theme, colorizer);
|
|
49
51
|
}
|
|
50
52
|
if (tokenRate && decorations.tokenRateDimmed) tokenRate = dimAttribute(tokenRate);
|
|
51
53
|
}
|
|
52
54
|
return buildWorkingMessage(theme, {
|
|
53
55
|
spinner, text, meter,
|
|
54
|
-
elapsed: features.elapsedTime && parts.elapsedMs !== undefined ?
|
|
56
|
+
elapsed: features.elapsedTime && parts.elapsedMs !== undefined ? formatElapsedUnits(parts.elapsedMs) : "",
|
|
55
57
|
tokens: features.outputTokens && parts.outputTokens !== undefined ? `↓ ${formatTokens(parts.outputTokens)} tokens` : "",
|
|
56
58
|
tokenRate, responseModel,
|
|
57
59
|
}, loaderOrder);
|
package/src/nvidia-green.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
2
|
+
import { thinkingLevelColorizer } from "@underactive/pi-topping-primitives/activity-tui";
|
|
3
|
+
import {
|
|
4
|
+
hexToFgAnsi,
|
|
5
|
+
isSwitchyardProvider,
|
|
6
|
+
NVIDIA_GREEN_HEX,
|
|
7
|
+
SWITCHYARD_PROVIDER,
|
|
8
|
+
} from "@underactive/pi-topping-primitives/color";
|
|
9
|
+
import type { ThinkingLevel } from "./format.ts";
|
|
3
10
|
import type { ThinkingLevelColor } from "./settings.ts";
|
|
4
11
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
const NVIDIA_GREEN_RGB = [1, 3, 5].map((offset) => Number.parseInt(NVIDIA_GREEN_HEX.slice(offset, offset + 2), 16));
|
|
12
|
-
const NVIDIA_GREEN_ANSI = `\x1b[38;2;${NVIDIA_GREEN_RGB.join(";")}m`;
|
|
12
|
+
/**
|
|
13
|
+
* The Switchyard provider id, the bright NVIDIA green it shares with pi-topping-statusline's
|
|
14
|
+
* Switchyard border, and the case- and space-insensitive provider check.
|
|
15
|
+
*/
|
|
16
|
+
export { isSwitchyardProvider, NVIDIA_GREEN_HEX, SWITCHYARD_PROVIDER };
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
18
|
+
/** Always truecolor, as the response-model colorizer has always been. */
|
|
19
|
+
const NVIDIA_GREEN_ANSI = hexToFgAnsi(NVIDIA_GREEN_HEX, "truecolor");
|
|
17
20
|
|
|
18
21
|
export function colorizeNvidiaGreen(text: string): string {
|
|
19
22
|
return `${NVIDIA_GREEN_ANSI}${text}\x1b[39m`;
|
|
@@ -33,5 +36,5 @@ export function getResponseModelColorizer(
|
|
|
33
36
|
): (text: string) => string {
|
|
34
37
|
return isSwitchyardProvider(provider)
|
|
35
38
|
? colorizeNvidiaGreen
|
|
36
|
-
:
|
|
39
|
+
: thinkingLevelColorizer(theme, color, thinkingLevel);
|
|
37
40
|
}
|
package/src/output-style.ts
CHANGED
|
@@ -132,13 +132,6 @@ export function isOutputStyleId(value: unknown): value is OutputStyleId {
|
|
|
132
132
|
return typeof value === "string" && OUTPUT_STYLE_IDS.some(id => id === value);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** Map the old pi-topping-insights style spelling to the current lowercase id. */
|
|
136
|
-
export function fromLegacyOutputStyle(value: unknown): OutputStyleId | undefined {
|
|
137
|
-
if (value === "default") return "default";
|
|
138
|
-
if (value === "Explanatory") return "explanatory";
|
|
139
|
-
return undefined;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
135
|
/**
|
|
143
136
|
* Apply a style's prompt section and return its hidden reminder. Default removes the section only
|
|
144
137
|
* when it holds one of these styles' text, so another extension's section survives. Hosts without
|
|
@@ -170,31 +163,3 @@ export function applyOutputStyle(
|
|
|
170
163
|
},
|
|
171
164
|
};
|
|
172
165
|
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Section value to send when the transcript's folded `output_style` text is `current`.
|
|
176
|
-
* `null` removes the section. `undefined` means the transcript already matches `id`.
|
|
177
|
-
*/
|
|
178
|
-
export function outputStyleSectionPatch(current: string | undefined, id: OutputStyleId): string | null | undefined {
|
|
179
|
-
const sections: Record<string, string> = {};
|
|
180
|
-
if (current !== undefined) sections[OUTPUT_STYLE_SECTION] = current;
|
|
181
|
-
applyOutputStyle(sections, id);
|
|
182
|
-
const next = sections[OUTPUT_STYLE_SECTION];
|
|
183
|
-
if (next === current) return undefined;
|
|
184
|
-
return next ?? null;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Last `output_style` section after replaying system messages. A `null` value clears it. */
|
|
188
|
-
export function foldedOutputStyleSection(
|
|
189
|
-
messages: readonly { role: string; sections?: Record<string, string | null> }[],
|
|
190
|
-
): string | undefined {
|
|
191
|
-
let current: string | undefined;
|
|
192
|
-
for (const message of messages) {
|
|
193
|
-
if (message.role !== "system") continue;
|
|
194
|
-
const sections = message.sections;
|
|
195
|
-
if (!sections || !Object.prototype.hasOwnProperty.call(sections, OUTPUT_STYLE_SECTION)) continue;
|
|
196
|
-
const value = sections[OUTPUT_STYLE_SECTION];
|
|
197
|
-
current = value === null ? undefined : value;
|
|
198
|
-
}
|
|
199
|
-
return current;
|
|
200
|
-
}
|
package/src/pi-installer.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type PiInvocation,
|
|
3
|
+
resolvePiInvocation,
|
|
4
|
+
spawnCollect,
|
|
5
|
+
} from "@underactive/pi-topping-primitives/process";
|
|
2
6
|
|
|
3
7
|
export interface PiInstallResult {
|
|
4
8
|
code: number;
|
|
@@ -6,67 +10,46 @@ export interface PiInstallResult {
|
|
|
6
10
|
stderr: string;
|
|
7
11
|
}
|
|
8
12
|
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
export interface PiInstallOptions {
|
|
14
|
+
/** Run this instead of the pi hosting the session. For tests, which must not start a real pi. */
|
|
15
|
+
piInvocation?: PiInvocation;
|
|
16
|
+
}
|
|
11
17
|
|
|
12
|
-
|
|
13
|
-
if (!/^npm:@?[\w.-]+(\/[\w.-]+)?$/.test(spec)) {
|
|
14
|
-
return Promise.resolve({ code: 1, stdout: "", stderr: `invalid spec: ${spec}` });
|
|
15
|
-
}
|
|
16
|
-
return new Promise((resolve) => {
|
|
17
|
-
const isWindows = process.platform === "win32";
|
|
18
|
-
const [executable, args, options] = isWindows
|
|
19
|
-
? (["cmd.exe", ["/c", "pi", command, spec], { windowsHide: true }] as const)
|
|
20
|
-
: (["pi", [command, spec], {}] as const);
|
|
21
|
-
const processHandle = spawn(executable, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
22
|
-
let stdout = "";
|
|
23
|
-
let stderr = "";
|
|
24
|
-
let settled = false;
|
|
25
|
-
let closed = false;
|
|
26
|
-
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
18
|
+
const PACKAGE_SPEC = /^npm:@?[\w.-]+(\/[\w.-]+)?$/;
|
|
27
19
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
async function spawnPiPackageCommand(
|
|
21
|
+
command: "install" | "remove",
|
|
22
|
+
spec: string,
|
|
23
|
+
timeoutMs: number,
|
|
24
|
+
options: PiInstallOptions,
|
|
25
|
+
): Promise<PiInstallResult> {
|
|
26
|
+
if (!PACKAGE_SPEC.test(spec)) {
|
|
27
|
+
return { code: 1, stdout: "", stderr: `invalid spec: ${spec}` };
|
|
28
|
+
}
|
|
34
29
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
30
|
+
// The pi that hosts this session, so install and remove reach the same agent dir.
|
|
31
|
+
const invocation = options.piInvocation
|
|
32
|
+
? { command: options.piInvocation.command, args: [...options.piInvocation.args, command, spec], via: "script" as const }
|
|
33
|
+
: resolvePiInvocation([command, spec]);
|
|
34
|
+
|
|
35
|
+
let { command: executable, args } = invocation;
|
|
36
|
+
// Only `pi` on PATH can be a Windows `.cmd` shim, which a shell-less spawn cannot start. The
|
|
37
|
+
// spec was validated above, so it carries nothing for cmd.exe to re-parse.
|
|
38
|
+
if (invocation.via === "path" && process.platform === "win32") {
|
|
39
|
+
executable = "cmd.exe";
|
|
40
|
+
args = ["/c", "pi", command, spec];
|
|
41
|
+
}
|
|
46
42
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
});
|
|
50
|
-
processHandle.stderr?.on("data", (chunk: Buffer) => {
|
|
51
|
-
stderr += chunk.toString();
|
|
52
|
-
});
|
|
53
|
-
processHandle.on("error", (error) => {
|
|
54
|
-
finish({ code: 1, stdout, stderr: `${stderr}${stderr ? "\n" : ""}${error.message}` });
|
|
55
|
-
});
|
|
56
|
-
processHandle.on("close", (code) => {
|
|
57
|
-
closed = true;
|
|
58
|
-
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
59
|
-
finish({ code: code ?? 1, stdout, stderr });
|
|
60
|
-
});
|
|
61
|
-
});
|
|
43
|
+
const { code, stdout, stderr } = await spawnCollect(executable, args, { timeoutMs, windowsHide: true });
|
|
44
|
+
return { code, stdout, stderr };
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
/** Run `pi install <spec>`
|
|
65
|
-
export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
66
|
-
return spawnPiPackageCommand("install", spec, timeoutMs);
|
|
47
|
+
/** Run `pi install <spec>` with the pi hosting this session; a Windows `pi` on PATH goes through `cmd.exe`. */
|
|
48
|
+
export function spawnPiInstall(spec: string, timeoutMs: number, options: PiInstallOptions = {}): Promise<PiInstallResult> {
|
|
49
|
+
return spawnPiPackageCommand("install", spec, timeoutMs, options);
|
|
67
50
|
}
|
|
68
51
|
|
|
69
|
-
/** Run `pi remove <spec>`
|
|
70
|
-
export function spawnPiRemove(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
71
|
-
return spawnPiPackageCommand("remove", spec, timeoutMs);
|
|
52
|
+
/** Run `pi remove <spec>` with the pi hosting this session; a Windows `pi` on PATH goes through `cmd.exe`. */
|
|
53
|
+
export function spawnPiRemove(spec: string, timeoutMs: number, options: PiInstallOptions = {}): Promise<PiInstallResult> {
|
|
54
|
+
return spawnPiPackageCommand("remove", spec, timeoutMs, options);
|
|
72
55
|
}
|