@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 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. When migrating from `pi-topping-insights`, its saved style is imported until you next save `/topping-settings`. Remove the old extension to avoid duplicate hidden reminders. `/insights` is gone; use `/topping-settings`.
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.12.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.3.0",
34
- "@earendil-works/pi-tui": "1.3.0",
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
- /** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
5
- export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
-
7
- /** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
8
- export const SPINNER_FRAME_MS = 80;
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 type ThinkingLevel = Parameters<Theme["getThinkingBorderColor"]>[0];
14
-
15
- export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
16
-
17
- export function isThinkingLevel(value: unknown): value is ThinkingLevel {
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
- }
@@ -1,5 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, fadeThemeColorString, formatElapsed, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES, TOKEN_RATE_PLACEHOLDER, type LoaderElement, type ThinkingLevel } from "./format.ts";
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 ? "" : getThinkingLevelColorizer(theme, decorations.spinnerColor, thinkingLevel)(SPINNER_FRAMES[Math.floor(parts.spinnerAtMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
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(getThinkingLevelColorizer(theme, decorations.meterColor, thinkingLevel), decorations.meterDimmed)
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 = getThinkingLevelColorizer(theme, decorations.tokenRateColor, thinkingLevel);
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 ? formatElapsed(parts.elapsedMs) : "",
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);
@@ -1,19 +1,22 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import { getThinkingLevelColorizer, type ThinkingLevel } from "./format.ts";
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
- /** Provider id whose working-loader response model uses NVIDIA green. */
6
- export const SWITCHYARD_PROVIDER = "switchyard";
7
-
8
- /** Bright NVIDIA green shared with pi-topping-statusline's Switchyard border. */
9
- export const NVIDIA_GREEN_HEX = "#84c51a";
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
- export function isSwitchyardProvider(provider: string | undefined): boolean {
15
- return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
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
- : getThinkingLevelColorizer(theme, color, thinkingLevel);
39
+ : thinkingLevelColorizer(theme, color, thinkingLevel);
37
40
  }
@@ -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
@@ -1,4 +1,8 @@
1
- import { spawn } from "node:child_process";
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
- const SIGKILL_GRACE_MS = 5_000;
10
- const EXIT_TIMEOUT = 124;
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
- function spawnPiPackageCommand(command: "install" | "remove", spec: string, timeoutMs: number): Promise<PiInstallResult> {
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
- const finish = (result: PiInstallResult): void => {
29
- if (settled) return;
30
- settled = true;
31
- clearTimeout(timeoutTimer);
32
- resolve(result);
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
- const timeoutTimer = setTimeout(() => {
36
- processHandle.kill("SIGTERM");
37
- killTimer = setTimeout(() => {
38
- if (!closed) processHandle.kill("SIGKILL");
39
- }, SIGKILL_GRACE_MS);
40
- finish({
41
- code: EXIT_TIMEOUT,
42
- stdout,
43
- stderr: `${stderr}${stderr ? "\n" : ""}[timed out after ${timeoutMs}ms]`,
44
- });
45
- }, timeoutMs);
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
- processHandle.stdout?.on("data", (chunk: Buffer) => {
48
- stdout += chunk.toString();
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>` without relying on Pi's shell-less exec wrapper on Windows. */
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>` without relying on Pi's shell-less exec wrapper on Windows. */
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 { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
- import { ActivityMeter, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN, rateToLevel } from "./activity-meter.ts";
4
- import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRate, getThinkingLevelColorizer, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
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: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string, width: number = DEFAULT_PREVIEW_WIDTH): PreviewResult {
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: Record<string, boolean | string>, elapsedMs: number): string {
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.render((level, char) => ActivityMeter.colorizeCell(level, char, theme, colorizer, dimmed)),
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: Record<string, boolean | string>): Record<string, boolean> {
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: Record<string, boolean | string>): PreviewResult {
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: Record<string, boolean | string>, width: number): PreviewResult {
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: Record<string, boolean | string>, width: number): PreviewResult {
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 = getThinkingLevelColorizer(theme, color, this.#ctx.thinkingLevel)("test-model");
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);