@underactive/pi-topping 0.12.0 → 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 +1 -1
- 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 -7
- package/src/pi-installer.ts +39 -56
- package/src/preview.ts +29 -12
- package/src/prompt-decorator.ts +17 -19
- package/src/session.ts +51 -81
- package/src/settings.ts +89 -83
- package/src/setup-check.ts +1 -1
- package/src/setup-command.ts +9 -4
- package/src/util.ts +3 -21
- 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
|
@@ -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
|
|
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
|
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
|
}
|
package/src/preview.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import { ActivityMeter, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN, rateToLevel } from "
|
|
4
|
-
import {
|
|
2
|
+
import type { MenuValue, PreviewSlot } from "@underactive/pi-topping-primitives/menu";
|
|
3
|
+
import { ActivityMeter, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN, rateToLevel } from "@underactive/pi-topping-primitives/activity";
|
|
4
|
+
import { dimAttribute, renderMeter, thinkingLevelColorizer } from "@underactive/pi-topping-primitives/activity-tui";
|
|
5
|
+
import { DEFAULT_WORKING_WORD, ELAPSED_INTERVAL_MS, formatTokenRate, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
|
|
5
6
|
import { buildLoaderMessage } from "./loader-message.ts";
|
|
6
7
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
7
8
|
import { isOutputStyleId, OUTPUT_STYLES } from "./output-style.ts";
|
|
@@ -12,12 +13,24 @@ import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, w
|
|
|
12
13
|
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
|
|
13
14
|
// flat 28 tps for the token readouts.
|
|
14
15
|
const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
16
|
+
/** Width the preview assumes when rendered outside a menu (tests and probes). */
|
|
17
|
+
export const DEFAULT_PREVIEW_WIDTH = 76;
|
|
18
|
+
type Values = Record<string, MenuValue>;
|
|
19
|
+
type PreviewState = { elapsedMs: number; activeItemId: string | undefined; width: number };
|
|
20
|
+
export interface PreviewResult {
|
|
21
|
+
lines: string[];
|
|
22
|
+
nextRefreshInMs?: number;
|
|
23
|
+
}
|
|
24
|
+
/** The shared menu adds no margin; the preview indents every line by one space itself. */
|
|
25
|
+
function indented(result: PreviewResult): PreviewResult {
|
|
26
|
+
return { ...result, lines: result.lines.map(line => ` ${line}`) };
|
|
27
|
+
}
|
|
15
28
|
const PROMPT_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "User Prompt").map(entry => entry.id));
|
|
16
29
|
const MARKER_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "Completion Marker").map(entry => entry.id));
|
|
17
30
|
function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
|
|
18
31
|
|
|
19
32
|
/** Stateful, per-menu preview renderer that follows the active settings section. */
|
|
20
|
-
export class PreviewRenderer {
|
|
33
|
+
export class PreviewRenderer implements PreviewSlot<Values> {
|
|
21
34
|
// Preserve constructor-time randomness so rendering never changes the preview phrase.
|
|
22
35
|
readonly #poolFraction = Math.random();
|
|
23
36
|
readonly #packs: readonly WordPack[];
|
|
@@ -30,7 +43,11 @@ export class PreviewRenderer {
|
|
|
30
43
|
this.#ctx = ctx;
|
|
31
44
|
this.#packs = packs;
|
|
32
45
|
}
|
|
33
|
-
render(values:
|
|
46
|
+
render(values: Values, state: PreviewState = { elapsedMs: 0, activeItemId: undefined, width: DEFAULT_PREVIEW_WIDTH }): PreviewResult {
|
|
47
|
+
return indented(this.renderContent(values, state.elapsedMs, state.activeItemId, Math.max(0, state.width - 1)));
|
|
48
|
+
}
|
|
49
|
+
/** The lines before the one-space indent; `width` is the budget those lines may fill. */
|
|
50
|
+
private renderContent(values: Values, elapsedMs: number, activeItemId: string | undefined, width: number): PreviewResult {
|
|
34
51
|
if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
|
|
35
52
|
if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
|
|
36
53
|
if (activeItemId === "useNerdFont") return { lines: ["", `Icon preview: ${values.useNerdFont ? "" : "π"}`, ""] };
|
|
@@ -60,7 +77,7 @@ export class PreviewRenderer {
|
|
|
60
77
|
: "";
|
|
61
78
|
return { lines: ["", this.loaderPreview(values, elapsedMs), help], nextRefreshInMs };
|
|
62
79
|
}
|
|
63
|
-
private loaderPreview(values:
|
|
80
|
+
private loaderPreview(values: Values, elapsedMs: number): string {
|
|
64
81
|
this.#meter.setDirection(fromCycleDirection(values.meterDirection));
|
|
65
82
|
if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) {
|
|
66
83
|
this.#meter.push(rateToLevel(meterRate(elapsedMs), fromCycleMeterUpperBound(values.meterUpperBoundTps)));
|
|
@@ -97,14 +114,14 @@ export class PreviewRenderer {
|
|
|
97
114
|
spinnerAtMs: values.animatedSpinner !== false ? elapsedMs : undefined,
|
|
98
115
|
word,
|
|
99
116
|
shimmerAtMs: elapsedMs,
|
|
100
|
-
renderMeter: (colorizer, dimmed) => this.#meter
|
|
117
|
+
renderMeter: (colorizer, dimmed) => renderMeter(this.#meter, theme, colorizer, dimmed),
|
|
101
118
|
elapsedMs,
|
|
102
119
|
outputTokens: Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)),
|
|
103
120
|
tokenRateText: formatTokenRate(TOKEN_RATE_PER_SEC),
|
|
104
121
|
responseModel: features.responseModel ? "test-model" : undefined,
|
|
105
122
|
});
|
|
106
123
|
}
|
|
107
|
-
private packValues(values:
|
|
124
|
+
private packValues(values: Values): Record<string, boolean> {
|
|
108
125
|
const enabled: Record<string, boolean> = {};
|
|
109
126
|
for (const pack of this.#packs) {
|
|
110
127
|
const value = values[`${WORD_PACK_MENU_PREFIX}${pack.id}`];
|
|
@@ -112,14 +129,14 @@ export class PreviewRenderer {
|
|
|
112
129
|
}
|
|
113
130
|
return enabled;
|
|
114
131
|
}
|
|
115
|
-
private packPreview(id: string, values:
|
|
132
|
+
private packPreview(id: string, values: Values): PreviewResult {
|
|
116
133
|
const pack = this.#packs.find((candidate) => candidate.id === id);
|
|
117
134
|
if (!pack) return { lines: ["This word pack is unavailable."] };
|
|
118
135
|
const enabled = isWordPackEnabled(id, this.packValues(values));
|
|
119
136
|
const source = pack.bundled ? "Shipped example" : `Custom: ${wordPacksPath()}`;
|
|
120
137
|
return { lines: [`${pack.name}: ${enabled ? "enabled" : "disabled"}`, `${pack.words.length} phrases · ${source}`, ...(pack.description ? [pack.description] : [])] };
|
|
121
138
|
}
|
|
122
|
-
private promptPreview(values:
|
|
139
|
+
private promptPreview(values: Values, width: number): PreviewResult {
|
|
123
140
|
if (values.decorateUserPrompt !== true) return { lines: ["User prompt decoration is disabled."] };
|
|
124
141
|
|
|
125
142
|
const borderColor = values.borderColor;
|
|
@@ -141,7 +158,7 @@ export class PreviewRenderer {
|
|
|
141
158
|
? { lines }
|
|
142
159
|
: { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
|
|
143
160
|
}
|
|
144
|
-
private markerPreview(values:
|
|
161
|
+
private markerPreview(values: Values, width: number): PreviewResult {
|
|
145
162
|
if (values.doneMarker !== true) return { lines: ["Completion marker is disabled."] };
|
|
146
163
|
const theme = this.#ctx.ui.theme;
|
|
147
164
|
const icon = values.doneMarkerIcon === true ? theme.fg("text", values.useNerdFont === true ? "" : "π") : "";
|
|
@@ -153,7 +170,7 @@ export class PreviewRenderer {
|
|
|
153
170
|
let model = "";
|
|
154
171
|
if (values.doneMarkerModel === true) {
|
|
155
172
|
const color = isThinkingLevelColor(values.doneMarkerModelColor) ? values.doneMarkerModelColor : DEFAULT_SETTINGS.decorations.doneMarkerModelColor;
|
|
156
|
-
const modelColored =
|
|
173
|
+
const modelColored = thinkingLevelColorizer(theme, color, this.#ctx.thinkingLevel)("test-model");
|
|
157
174
|
model = values.doneMarkerModelDimmed === true ? dimAttribute(modelColored) : modelColored;
|
|
158
175
|
}
|
|
159
176
|
const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details, model);
|