@underactive/pi-topping 0.6.5 → 0.7.1

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
@@ -31,12 +31,12 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
31
31
  ╔═[ Pi Topping: Settings ]══════════════════════════════════════╗
32
32
  ╟─ Preview ─────────────────────────────────────────────────────╢
33
33
  ║ ║
34
- ║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
34
+ ║ ⠋ Crafting ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
35
35
  ║ ║
36
36
  ╟─ User Prompt ─────────────────────────────────────────────────╢
37
37
  ║ [■] High-vis prompt ON ║
38
38
  ║ [■] Border style ‹ double › ║
39
- ║ [■] Border color ‹ borderAccent › ║
39
+ ║ [■] Border color ‹ thinkingLevel › ║
40
40
  ║ [■] Pi icon ON ║
41
41
  ║ [■] Timestamp ON ║
42
42
  ║ [■] Provider ON ║
@@ -44,7 +44,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
44
44
  ║ ║
45
45
  ╟─ “Working” Loader ────────────────────────────────────────────╢
46
46
  ║ [■] Animated spinner ON ║
47
- ║ ❯ [■] Animated spinner color ‹ accent › ║
47
+ ║ ❯ [■] Animated spinner color ‹ thinkingLevel › ║
48
48
  ║ [■] Randomize “Working” text ON ║
49
49
  ║ [■] Text shimmer ON ║
50
50
  ║ [■] Invert shimmer OFF ║
@@ -76,7 +76,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
76
76
  ║ [■] Show completion marker ON ║
77
77
  ║ [■] Marker style ‹ elite › ║
78
78
  ║ [■] Border style ‹ none › ║
79
- ║ [■] Border color ‹ borderAccent › ║
79
+ ║ [■] Border color ‹ thinkingLevel › ║
80
80
  ║ [■] Pi icon ON ║
81
81
  ║ [■] Randomize “Worked” text ON ║
82
82
  ║ [■] Tokens spent ON ║
@@ -112,12 +112,15 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
112
112
  "packs": [{
113
113
  "id": "cooking",
114
114
  "name": "Cooking",
115
+ "description": "Kitchen operations.",
115
116
  "words": [{ "present_tense": "Sautéing onions", "past_tense": "Sautéed" }]
116
117
  }]
117
118
  }
118
119
  ```
119
120
 
120
- Pack IDs must start with a lowercase letter and contain only lowercase letters, digits, and `-`. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
121
+ Pack IDs must start with a lowercase letter, contain only lowercase letters, digits, and `-`, and be at most 64 characters. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. An optional `description` appears in the settings preview when the pack row is selected, and an optional `attribution` records the source. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
122
+
123
+ Every color setting offers `thinking-level` (shown as `thinkingLevel` in `/topping-settings`); it is the default for the animated spinner, the prompt border, and the completion marker border. The spinner, token activity monitor, token rate, and response model follow Pi's current thinking level. The prompt border uses the level captured when the prompt was submitted, and the completion marker uses the level captured when the turn settled, so earlier entries keep their color after you change level. When hand-editing `settings.json`, write `thinking-level`; the legacy `default` spelling is only recognised for `spinnerColor` and `doneMarkerBorderColor`.
121
124
 
122
125
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
123
126
  left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
@@ -136,6 +139,7 @@ The response model is captured from assistant responses, displayed as its saniti
136
139
  ## Requirements
137
140
 
138
141
  - [Pi](https://github.com/earendil-works/pi-coding-agent). Pi ≥0.84.4 is required for the waiting-for-input loader state; older Pi versions keep the animated loader.
142
+ - 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.
139
143
  - Node.js ≥22.19.0
140
144
 
141
145
  ## Development
@@ -146,7 +150,7 @@ npm test
146
150
  npm run typecheck
147
151
  ```
148
152
 
149
- Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component, and the settings menu wiring end to end.
153
+ Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence and schema migrations, word-pack parsing and loading, the `menu.ts` component, the settings menu wiring end to end, and the flag-gated `/topping-setup` command's install and uninstall flows.
150
154
 
151
155
  ## Limitations
152
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.6.5",
3
+ "version": "0.7.1",
4
4
  "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -123,16 +123,16 @@ export class ActivityMeter {
123
123
  this.#levels.fill(0);
124
124
  }
125
125
 
126
- /** Colorize a meter cell using the default theme mapping (dim at IDLE, `color` otherwise). */
126
+ /** Colorize a meter cell (dim at IDLE, `colorize` otherwise). */
127
127
  static colorizeCell(
128
128
  level: ActivityMeterLevel,
129
129
  char: string,
130
130
  theme: { fg: (style: ThemeColor, s: string) => string },
131
- color: ThemeColor = "accent",
131
+ colorize: (text: string) => string,
132
132
  dimmed?: boolean,
133
133
  ): string {
134
134
  if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
135
- const colored = theme.fg(color, char);
135
+ const colored = colorize(char);
136
136
  return dimmed ? dimAttribute(colored) : colored;
137
137
  }
138
138
  }
package/src/format.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { ThinkingLevelColor, ThinkingLevelSettingColor } from "./settings.ts";
2
3
 
3
4
  /** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
4
5
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -6,8 +7,27 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
6
7
  /** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
7
8
  export const SPINNER_FRAME_MS = 80;
8
9
 
9
- /** Default "Working…" text shown when text randomization/substitution is off. */
10
- export const DEFAULT_WORKING_WORD = "Working…";
10
+ /** Default "Working" text shown when text randomization/substitution is off. */
11
+ export const DEFAULT_WORKING_WORD = "Working";
12
+
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 | ThinkingLevelSettingColor,
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
+ }
11
31
 
12
32
  /** Milliseconds between activity meter sample pushes. */
13
33
  export const METER_INTERVAL_MS = 100;
@@ -24,7 +44,7 @@ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens"
24
44
  /** Default first-use working-indicator element order. */
25
45
  export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens", "responseModel"];
26
46
 
27
- /** Elements share a detail group; separators and non-rate details are dimmed. */
47
+ /** Elements share a detail group; separators are dimmed and non-rate details use muted. */
28
48
  const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate", "responseModel"]);
29
49
 
30
50
  const TOKEN_UNITS = [
@@ -71,7 +91,7 @@ export function dimAttribute(text: string): string {
71
91
  return `\x1b[2m${text}\x1b[22m`;
72
92
  }
73
93
 
74
- /** Number of discrete, eased warning-to-dim shades used for the token-rate fade. */
94
+ /** Number of discrete, eased shades used by the token-rate and response-model fades. */
75
95
  export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
76
96
 
77
97
  /** Milliseconds a settled response model remains fully visible before fading. */
@@ -80,17 +100,17 @@ export const RESPONSE_MODEL_HOLD_MS = 3_000;
80
100
  /** Milliseconds over which a settled response model fades through the theme shades. */
81
101
  export const RESPONSE_MODEL_FADE_MS = 500;
82
102
 
83
- /** Render one token-rate fade shade by blending the selected color toward the active theme's dim color. */
103
+ /** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
84
104
  export function fadeThemeColorString(
85
105
  text: string,
86
106
  shade: number,
87
- theme: Pick<Theme, "getFgAnsi" | "fg">,
88
- color: ThemeColor,
107
+ theme: Pick<Theme, "getFgAnsi">,
108
+ colorizer: (text: string) => string,
89
109
  ): string {
90
110
  if (!text) return "";
91
- const source = ansiToRgb(theme.getFgAnsi(color));
111
+ const source = ansiToRgb(colorizer("x"));
92
112
  const dim = ansiToRgb(theme.getFgAnsi("dim"));
93
- if (!source || !dim) return theme.fg(color, text);
113
+ if (!source || !dim) return colorizer(text);
94
114
  const clampedShade = Math.max(0, Math.min(TOKEN_RATE_FADE_SHADE_COUNT - 1, Math.floor(shade)));
95
115
  const progress = (clampedShade + 1) / TOKEN_RATE_FADE_SHADE_COUNT;
96
116
  const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
@@ -151,7 +171,7 @@ export function buildWorkingMessage(
151
171
  if (!value) continue;
152
172
  if (DETAIL_ELEMENTS.has(element)) {
153
173
  // tokenRate and responseModel arrive pre-colored; dimming them here would replace their configured colors.
154
- details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("dim", value));
174
+ details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("muted", value));
155
175
  continue;
156
176
  }
157
177
  flushDetails();
@@ -220,7 +240,7 @@ export function shimmerString(
220
240
  }
221
241
 
222
242
  function ansiToRgb(ansi: string): [number, number, number] | null {
223
- const match = ansi.match(/^\x1b\[38;2;(\d+);(\d+);(\d+)m$/);
243
+ const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
224
244
  if (!match) return null;
225
245
  return [Number(match[1]), Number(match[2]), Number(match[3])];
226
246
  }
package/src/menu.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * contract and `ctx.ui.custom()` overlay API.
5
5
  *
6
6
  * Renders a titled box containing one or more sections of boolean toggle,
7
- * multi-value cycle, or drag-to-reorder items, plus an optional live-updating
7
+ * multi-value cycle, or keyboard-reorderable items, plus an optional live-updating
8
8
  * preview section driven by a caller-supplied render callback.
9
9
  *
10
10
  * Intended to be reused by any extension that needs a simple modal toggle
@@ -28,6 +28,8 @@ export interface MenuItem {
28
28
  value: MenuValue;
29
29
  /** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
30
30
  cycleValues?: readonly string[];
31
+ /** Optional display labels for cycle values; the keys remain the published values. */
32
+ cycleValueLabels?: Readonly<Record<string, string>>;
31
33
  /** ID of the boolean value that gates cycling; space toggles it. */
32
34
  cycleEnabledBy?: string;
33
35
  /** Initial enabled state when `cycleEnabledBy` is set (default true). */
@@ -55,15 +57,14 @@ export interface MenuConfig {
55
57
  /**
56
58
  * Optional preview renderer, shown in its own "Preview" section above the
57
59
  * toggle sections. Called on every render with the menu's current (possibly
58
- * toggled but not-yet-applied) values and the number of milliseconds elapsed
59
- * since the menu opened. Lines may contain ANSI styling and are truncated/
60
- * padded to fit automatically.
60
+ * toggled but not-yet-applied) values, the milliseconds elapsed since the menu
61
+ * opened, the id of the item under the cursor (undefined for an empty menu),
62
+ * and the width available to each line. Lines may contain ANSI styling and
63
+ * are truncated/padded to fit automatically.
61
64
  *
62
- * Return `string[]` for legacy interval-based animation (defaults to 50ms
63
- * refresh), or a `PreviewResult` to declare the next refresh interval.
64
- * Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
65
+ * Omitting `nextRefreshInMs` (or returning a value <= 0) makes the preview static.
65
66
  */
66
- preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => string[] | PreviewResult;
67
+ preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult;
67
68
  /** Optional heading for the preview block. Defaults to `Preview`. */
68
69
  previewTitle?: string;
69
70
  /** Maximum overlay height in rows or as a percentage of the terminal height. */
@@ -88,12 +89,6 @@ function closeTruncatedHyperlink(text: string): string {
88
89
  }
89
90
 
90
91
  interface FlatItem {
91
- id: string;
92
- label: string;
93
- cycleValues?: readonly string[];
94
- cycleEnabledBy?: string;
95
- cycleDisabledValue?: string;
96
- reorderGroup?: string;
97
92
  item: MenuItem;
98
93
  sectionIndex: number;
99
94
  }
@@ -159,7 +154,7 @@ export class MenuComponent implements Component {
159
154
  this.flat = [];
160
155
  for (const [sectionIndex, section] of config.sections.entries()) {
161
156
  for (const item of section.items) {
162
- this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, reorderGroup: item.reorderGroup, item, sectionIndex });
157
+ this.flat.push({ item, sectionIndex });
163
158
  }
164
159
  }
165
160
  this.initialValues = { ...this.values };
@@ -219,7 +214,7 @@ export class MenuComponent implements Component {
219
214
  [Key.left]: () => this.cycleCurrentValue(-1),
220
215
  [Key.right]: () => this.cycleCurrentValue(1),
221
216
  [Key.space]: () => {
222
- const item = this.flat[this.cursor]!;
217
+ const item = this.flat[this.cursor]!.item;
223
218
  if (item.cycleValues && item.cycleEnabledBy) {
224
219
  this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
225
220
  if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
@@ -258,20 +253,20 @@ export class MenuComponent implements Component {
258
253
  */
259
254
  private moveGrabbedItem(delta: number): boolean {
260
255
  const grabbed = this.flat[this.cursor]!;
261
- const group = grabbed.reorderGroup;
262
- if (group === undefined || this.values[grabbed.id] !== true) return false;
256
+ const group = grabbed.item.reorderGroup;
257
+ if (group === undefined || this.values[grabbed.item.id] !== true) return false;
263
258
 
264
259
  let start = this.cursor;
265
- while (start > 0 && this.flat[start - 1]!.reorderGroup === group) start--;
260
+ while (start > 0 && this.flat[start - 1]!.item.reorderGroup === group) start--;
266
261
  let end = this.cursor;
267
- while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
262
+ while (end < this.flat.length - 1 && this.flat[end + 1]!.item.reorderGroup === group) end++;
268
263
 
269
264
  const target = Math.max(start, Math.min(end, this.cursor + delta));
270
265
  if (target !== this.cursor) {
271
266
  this.flat.splice(this.cursor, 1);
272
267
  this.flat.splice(target, 0, grabbed);
273
268
  this.cursor = target;
274
- this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
269
+ this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.item.id).join(",");
275
270
  this.invalidate();
276
271
  }
277
272
  return true;
@@ -292,7 +287,7 @@ export class MenuComponent implements Component {
292
287
  }
293
288
 
294
289
  private cycleCurrentValue(delta: number): void {
295
- const item = this.flat[this.cursor]!;
290
+ const item = this.flat[this.cursor]!.item;
296
291
  if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
297
292
  const current = item.cycleValues.indexOf(this.values[item.id] as string);
298
293
  const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
@@ -306,14 +301,9 @@ export class MenuComponent implements Component {
306
301
  const result = this.previewFn(
307
302
  this.values,
308
303
  this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
309
- this.flat[this.cursor]?.id,
304
+ this.flat[this.cursor]?.item.id,
310
305
  width,
311
306
  );
312
- if (Array.isArray(result)) {
313
- this.previewNextRefreshMs = 50;
314
- return result;
315
- }
316
-
317
307
  this.previewNextRefreshMs = result.nextRefreshInMs;
318
308
  return result.lines;
319
309
  }
@@ -382,12 +372,7 @@ export class MenuComponent implements Component {
382
372
  }
383
373
 
384
374
  /** Render the scrolling settings body while keeping the selected item in view. */
385
- private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
386
- if (allLines.length <= maxRows) {
387
- this.scrollStart = 0;
388
- this.pageItemCount = Math.max(1, this.flat.length);
389
- return allLines;
390
- }
375
+ private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
391
376
  if (maxRows <= 0) {
392
377
  this.pageItemCount = 1;
393
378
  return [];
@@ -452,15 +437,16 @@ export class MenuComponent implements Component {
452
437
  const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
453
438
  const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
454
439
  const footer = this.buildFooter(innerWidth);
455
- const naturalBody = this.buildToggleSections(innerWidth);
456
- const naturalHeight = header.length + naturalBody.length + footer.length;
457
- if (maxRows === undefined || naturalHeight <= maxRows) {
440
+ const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size;
441
+ const naturalBodyLength = this.flat.length + 2 * sectionsWithItems;
442
+ if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) {
443
+ const naturalBody = this.buildToggleSections(innerWidth);
458
444
  this.pageItemCount = Math.max(1, this.flat.length);
459
445
  return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
460
446
  }
461
447
 
462
448
  const bodyRows = Math.max(0, maxRows - header.length - footer.length);
463
- const body = this.buildResponsiveToggleSections(innerWidth, bodyRows, naturalBody);
449
+ const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
464
450
  return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
465
451
  }
466
452
 
@@ -557,7 +543,9 @@ export class MenuComponent implements Component {
557
543
 
558
544
  if (item.cycleValues) {
559
545
  const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
560
- const stateWord = `‹ ${value} ›`;
546
+ const rawValue = typeof value === "string" ? value : String(value);
547
+ const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue;
548
+ const stateWord = `‹ ${displayValue} ›`;
561
549
  const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
562
550
  const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
563
551
  const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
@@ -9,17 +9,16 @@ export interface PiInstallResult {
9
9
  const SIGKILL_GRACE_MS = 5_000;
10
10
  const EXIT_TIMEOUT = 124;
11
11
 
12
- /** Run `pi install <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
13
- export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
12
+ function spawnPiPackageCommand(command: "install" | "remove", spec: string, timeoutMs: number): Promise<PiInstallResult> {
14
13
  if (!/^npm:@?[\w.-]+(\/[\w.-]+)?$/.test(spec)) {
15
14
  return Promise.resolve({ code: 1, stdout: "", stderr: `invalid spec: ${spec}` });
16
15
  }
17
16
  return new Promise((resolve) => {
18
17
  const isWindows = process.platform === "win32";
19
- const [command, args, options] = isWindows
20
- ? (["cmd.exe", ["/c", "pi", "install", spec], { windowsHide: true }] as const)
21
- : (["pi", ["install", spec], {}] as const);
22
- const processHandle = spawn(command, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
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"] });
23
22
  let stdout = "";
24
23
  let stderr = "";
25
24
  let settled = false;
@@ -61,3 +60,13 @@ export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInsta
61
60
  });
62
61
  });
63
62
  }
63
+
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);
67
+ }
68
+
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);
72
+ }
package/src/preview.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
3
  import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
4
- import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
4
+ import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
5
5
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
6
- import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isDoneMarkerStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
6
+ import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
7
7
  import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
8
- import { pickRandomWord } from "./words.ts";
9
8
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
10
9
  // flat 28 tps for the token readouts.
11
10
  const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
@@ -16,7 +15,6 @@ function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.
16
15
  /** Stateful, per-menu preview renderer that follows the active settings section. */
17
16
  export class PreviewRenderer {
18
17
  // Preserve constructor-time randomness so rendering never changes the preview phrase.
19
- readonly #word = pickRandomWord();
20
18
  readonly #poolFraction = Math.random();
21
19
  readonly #packs: readonly WordPack[];
22
20
  readonly #meter = new ActivityMeter();
@@ -54,17 +52,18 @@ export class PreviewRenderer {
54
52
  const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
55
53
  const decorations = { shimmer: values.shimmer !== false, shimmerInverted: values.shimmerInverted === true, tokenActivityMonitor: values.tokenActivityMonitor !== false };
56
54
  let spinnerColor = DEFAULT_SETTINGS.decorations.spinnerColor;
57
- if (values.spinnerColorEnabled !== false && isSettingColor(values.spinnerColor)) {
55
+ if (isSpinnerColor(values.spinnerColor)) {
58
56
  spinnerColor = values.spinnerColor;
59
57
  }
60
58
  let spinner = "";
61
59
  if (values.animatedSpinner !== false) {
62
- spinner = this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
60
+ spinner = getThinkingLevelColorizer(this.#ctx.ui.theme, spinnerColor, this.#ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
63
61
  }
64
62
  const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
65
63
  let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
66
- if (isSettingColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
67
- const responseModelColored = features.responseModel ? this.#ctx.ui.theme.fg(responseModelColor, "test-model") : "";
64
+ if (isThinkingLevelColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
65
+ const responseModelColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, responseModelColor, this.#ctx.thinkingLevel);
66
+ const responseModelColored = features.responseModel ? responseModelColorizer("test-model") : "";
68
67
  const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
69
68
  if (isFullyDefaultAppearance(features, decorations)) {
70
69
  return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, order);
@@ -74,8 +73,7 @@ export class PreviewRenderer {
74
73
  const packValues = this.packValues(values);
75
74
  const signature = JSON.stringify(Object.keys(packValues).sort().map(k => `${k}=${packValues[k]}`));
76
75
  if (signature !== this.#cachedPackSignature) {
77
- const enabledPacks = this.#packs.filter((pack) => isWordPackEnabled(pack.id, packValues));
78
- this.#cachedPackWord = enabledPacks.length ? selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text : this.#word;
76
+ this.#cachedPackWord = selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text;
79
77
  this.#cachedPackSignature = signature;
80
78
  }
81
79
  word = this.#cachedPackWord;
@@ -87,24 +85,26 @@ export class PreviewRenderer {
87
85
  styledWord = this.#ctx.ui.theme.fg("text", word);
88
86
  }
89
87
  let meterColor = DEFAULT_SETTINGS.decorations.meterColor;
90
- if (values.meterColorEnabled !== false && isSettingColor(values.meterColor)) {
88
+ if (isThinkingLevelColor(values.meterColor)) {
91
89
  meterColor = values.meterColor;
92
90
  }
93
91
  let meter = "";
94
92
  if (decorations.tokenActivityMonitor) {
95
- meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed === true));
93
+ const meterColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, meterColor, this.#ctx.thinkingLevel);
94
+ meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColorizer, values.meterDimmed === true));
96
95
  }
97
96
  let tokenRateText = "";
98
97
  if (features.tokenRate) {
99
98
  tokenRateText = formatTokenRate(TOKEN_RATE_PER_SEC);
100
99
  }
101
100
  let tokenRateColor = DEFAULT_SETTINGS.decorations.tokenRateColor;
102
- if (isSettingColor(values.tokenRateColor)) {
101
+ if (isThinkingLevelColor(values.tokenRateColor)) {
103
102
  tokenRateColor = values.tokenRateColor;
104
103
  }
105
104
  let tokenRateColored = "";
106
105
  if (tokenRateText) {
107
- tokenRateColored = this.#ctx.ui.theme.fg(tokenRateColor, tokenRateText);
106
+ const tokenRateColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, tokenRateColor, this.#ctx.thinkingLevel);
107
+ tokenRateColored = tokenRateColorizer(tokenRateText);
108
108
  }
109
109
  let tokenRate = tokenRateColored;
110
110
  if (tokenRateColored && values.tokenRateDimmed === true) {
@@ -157,8 +157,9 @@ export class PreviewRenderer {
157
157
  icon: values.useNerdFont ? "" : "π",
158
158
  provider: this.#ctx.model?.provider,
159
159
  model: this.#ctx.model?.id,
160
- borderColor: isSettingColor(borderColor) ? borderColor : DEFAULT_SETTINGS.decorations.borderColor,
160
+ borderColor: isPromptBorderColor(borderColor) ? borderColor : DEFAULT_SETTINGS.decorations.borderColor,
161
161
  borderStyle: isBorderStyle(borderStyle) ? borderStyle : "double",
162
+ thinkingLevel: this.#ctx.thinkingLevel,
162
163
  });
163
164
  return timestamp === undefined
164
165
  ? { lines }
@@ -174,9 +175,9 @@ export class PreviewRenderer {
174
175
  if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
175
176
  const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details);
176
177
  const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
177
- const borderColor = isSettingColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
178
+ const borderColor = isDoneMarkerBorderColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
178
179
  const markerStyle = isDoneMarkerStyle(values.doneMarkerStyle) ? values.doneMarkerStyle : DEFAULT_SETTINGS.decorations.doneMarkerStyle;
179
- const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle);
180
+ const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle, this.#ctx.thinkingLevel);
180
181
  return { lines: ["", marker, ""] };
181
182
  }
182
183
  }
@@ -1,6 +1,7 @@
1
- import type { MessageRenderer, ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
- import { DEFAULT_SETTINGS, isBorderStyle, isSettingColor, type BorderStyle, type DoneMarkerBorderStyle, type DoneMarkerStyle, type SettingColor } from "./settings.ts";
3
+ import { getThinkingLevelColorizer, isThinkingLevel, type ThinkingLevel } from "./format.ts";
4
+ import { DEFAULT_SETTINGS, isBorderStyle, isPromptBorderColor, type BorderStyle, type DoneMarkerBorderColor, type DoneMarkerBorderStyle, type DoneMarkerStyle, type PromptBorderColor } from "./settings.ts";
4
5
  import { stripControlChars } from "./util.ts";
5
6
 
6
7
  export const PROMPT_BOX_TYPE = "pi-topping-prompt";
@@ -14,7 +15,10 @@ const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; b
14
15
 
15
16
  /**
16
17
  * False visibility options hide each corresponding part; present provider and
17
- * model values join with `/` in the bottom border.
18
+ * model values join with `/` in the bottom border. `thinkingLevel` is the level
19
+ * captured at submission and is only used when `borderColor` is `thinking-level`.
20
+ * Unrecognised `borderStyle` / `borderColor` values fall back to `double` and the
21
+ * default border color.
18
22
  */
19
23
  export interface PromptBoxDetails {
20
24
  submittedAt?: number;
@@ -25,11 +29,15 @@ export interface PromptBoxDetails {
25
29
  icon?: string;
26
30
  provider?: string;
27
31
  model?: string;
28
- borderColor?: ThemeColor;
32
+ borderColor?: PromptBorderColor;
29
33
  borderStyle?: BorderStyle;
34
+ thinkingLevel?: ThinkingLevel;
30
35
  }
31
36
 
32
- type PromptTheme = { fg(color: string, text: string): string };
37
+ type PromptTheme = {
38
+ fg(color: string, text: string): string;
39
+ getThinkingBorderColor(level: ThinkingLevel): (text: string) => string;
40
+ };
33
41
 
34
42
  const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
35
43
 
@@ -51,15 +59,16 @@ export function buildCompletionMarkerLine(
51
59
  width: number,
52
60
  theme: PromptTheme,
53
61
  borderStyle: DoneMarkerBorderStyle,
54
- borderColor: SettingColor,
62
+ borderColor: DoneMarkerBorderColor,
55
63
  markerStyle: DoneMarkerStyle = DEFAULT_SETTINGS.decorations.doneMarkerStyle,
64
+ thinkingLevel?: ThinkingLevel,
56
65
  ): string {
57
66
  const safeWidth = Math.max(0, Math.floor(width));
58
67
  if (safeWidth === 0) return "";
59
68
  if (borderStyle === "none") return truncateToWidth(content, safeWidth);
60
69
 
61
70
  const g = BORDER_GLYPHS[borderStyle];
62
- const border = (text: string) => theme.fg(borderColor, text);
71
+ const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
63
72
  const prefix = `${g.bl}${g.h.repeat(2)} `;
64
73
 
65
74
  const suffix = markerStyle === "bookend"
@@ -81,8 +90,10 @@ export function buildPromptBoxLines(
81
90
 
82
91
  const borderStyle = isBorderStyle(options.borderStyle) ? options.borderStyle : "double";
83
92
  const g = BORDER_GLYPHS[borderStyle];
84
- const borderColor = isSettingColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
85
- const border = (text: string) => theme.fg(borderColor, text);
93
+ const borderColor = isPromptBorderColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
94
+ const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
95
+ const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
96
+ const iconColor = (text: string) => theme.fg("text", text);
86
97
  const label = (text: string) => theme.fg("customMessageLabel", text);
87
98
  const muted = (text: string) => theme.fg("dim", text);
88
99
  const bodyText = (text: string) => theme.fg("customMessageText", text);
@@ -113,7 +124,7 @@ export function buildPromptBoxLines(
113
124
  const titleLength = icon ? visibleWidth(`${g.h}${g.h} ${icon} `) : 0;
114
125
  const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
115
126
  const topFill = Math.max(0, innerWidth - titleLength - timeLength);
116
- const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${label(icon)}${border(" ")}` : "";
127
+ const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${iconColor(icon)}${border(" ")}` : "";
117
128
  const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : "";
118
129
  const topLine = border(g.tl) + iconSeg + border(g.h.repeat(topFill)) + timeSeg + border(g.tr);
119
130
  const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
@@ -138,16 +149,19 @@ export function buildPromptBoxLines(
138
149
  export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
139
150
  const content = typeof message.content === "string" ? message.content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "") : "";
140
151
  const details = (message.details ?? {}) as PromptBoxDetails;
141
- const linesByWidth = new Map<number, string[]>();
152
+ let cachedWidth: number | undefined;
153
+ let cachedLines: string[] | undefined;
142
154
  return {
143
155
  render(width: number): string[] {
144
- let lines = linesByWidth.get(width);
145
- if (!lines) {
146
- lines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
147
- linesByWidth.set(width, lines);
156
+ if (cachedWidth !== width) {
157
+ cachedWidth = width;
158
+ cachedLines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
148
159
  }
149
- return lines;
160
+ return cachedLines!;
161
+ },
162
+ invalidate() {
163
+ cachedWidth = undefined;
164
+ cachedLines = undefined;
150
165
  },
151
- invalidate() { linesByWidth.clear(); },
152
166
  };
153
167
  };