@underactive/pi-topping 0.5.1 → 0.6.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
@@ -6,11 +6,11 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
6
6
 
7
7
  ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_user_prompt.png)
8
8
 
9
- **“Working” Loader** — animated spinner (with color choice), randomized activity word, shimmer (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, and live output-token rate (with color choice and dim toggle) — all arrangeable left to right.
9
+ **“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, and live output-token rate (with color choice and dim toggle) — all arrangeable left to right.
10
10
 
11
11
  ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, token count, and token rate](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo.gif)
12
12
 
13
- **Completion Marker** — end-of-turn marker with icon, randomized verb, token consumption display, and optional border-style/color decorations. Border style defaults to `none`; `heavy` renders e.g. `┗━━ π Mustered for 4s (↓ 25 tokens) ━━━━━━ ━━━━ ━━ ━`. If you steered or followed up (`Alt+Enter`) while Pi was working, the marker also tallies those, e.g. `π Whisked for 2s (↓ 55 tokens · 2 mid-turn inputs)`. Hooks into Pi's `agent_settled` event.
13
+ **Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event.
14
14
 
15
15
  ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_completion_marker.png)
16
16
 
@@ -23,7 +23,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
23
23
  ╔═[ Pi Topping: Settings ]═══════════════════════════════════╗
24
24
  ╟─ Preview ──────────────────────────────────────────────────╢
25
25
  ║ ║
26
- ║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tok/s · 3s · ↓ 84 tokens ║
26
+ ║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tok/s · 3s · ↓ 84 tokens ║
27
27
  ║ ║
28
28
  ╟─ User Prompt ──────────────────────────────────────────────╢
29
29
  ║ [■] High-vis prompt ON ║
@@ -62,6 +62,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
62
62
  ║ ║
63
63
  ╟─ Completion Marker ────────────────────────────────────────╢
64
64
  ║ [■] Show completion marker ON ║
65
+ ║ [■] Marker style ‹ elite › ║
65
66
  ║ [■] Border style ‹ none › ║
66
67
  ║ [■] Border color ‹ borderAccent › ║
67
68
  ║ [■] Pi icon ON ║
@@ -69,22 +70,35 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
69
70
  ║ [■] Tokens spent ON ║
70
71
  ║ [■] Mid-turn inputs ON ║
71
72
  ║ ║
73
+ ╟─ Word Packs ───────────────────────────────────────────────╢
74
+ ║ [ ] SimCity OFF ║
75
+ ║ [ ] Star Trek OFF ║
76
+ ║ [ ] Star Wars OFF ║
77
+ ║ ║
72
78
  ╟─ Options ──────────────────────────────────────────────────╢
73
79
  ║ [■] Use NerdFont icons ON ║
74
80
  ╟────────────────────────────────────────────────────────────╢
75
81
  ║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
76
- ╚════════════════════════════════════════════════════[ 9/37 ]╝
82
+ ╚════════════════════════════════════════════════════[ 9/41 ]╝
77
83
  ```
78
84
 
79
- ❯ marks the keyboard cursor, and the selected row is highlighted. The Provider and Model toggles independently control the lower-right label on decorated prompts.
85
+ ### Word packs
80
86
 
81
- The User Prompt Border style cycles through `double`, `single`, `rounded`, and `heavy` box-drawing sets; the Border color row sits directly beneath it. The Completion Marker Border style offers those same choices plus `none`; `none` leaves the marker undecorated, and its Border color row sits directly beneath it.
87
+ Base activity words are always available. All word packs, including the shipped **SimCity**, **Star Trek**, and **Star Wars** packs and custom packs, are disabled by default. Enable packs in `/topping-settings` to add their entries to the same uniformly selected pool. When Randomize “Worked” text is on, the completion marker uses the selected entry’s matching past tense; otherwise it uses `Worked`.
82
88
 
83
- A cycle row's `[■]` box gates it: `␣` unchecks the box, snaps the setting back to its default, and stops `←`/`→` from cycling. The Completion Marker Border style and Border color rows, and Token rate color, have no gate — their box stays `[■]` and `␣` does nothing.
89
+ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/simcity.json`, `wordpacks/star-trek.json`, or `wordpacks/star-wars.json` to `~/.pi/agent/pi-topping/word-packs.json`, then change the pack `id` (the bundled `simcity`, `star-trek`, and `star-wars` IDs are reserved), name, and words:
84
90
 
85
- The User Prompt and Completion Marker Border color settings, along with the Animated spinner, Token activity monitor, and Token rate colors, cycle through `accent`, `border`, `borderAccent`, `success`, `error`, and `warning`.
91
+ ```json
92
+ {
93
+ "packs": [{
94
+ "id": "cooking",
95
+ "name": "Cooking",
96
+ "words": [{ "present_tense": "Sautéing onions", "past_tense": "Sautéed" }]
97
+ }]
98
+ }
99
+ ```
86
100
 
87
- Invert shimmer keeps the working text at the default text color and sweeps a dimmed gradient across it.
101
+ 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.
88
102
 
89
103
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
90
104
  left or right within the loader. Elapsed time, output tokens, and token rate are joined with
package/index.ts CHANGED
@@ -1,4 +1,3 @@
1
- /** pi-topping extension entry point. */
2
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
2
  import { SessionManager } from "./src/session.ts";
4
3
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.5.1",
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",
3
+ "version": "0.6.1",
4
+ "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
7
7
  "author": "Eric Sison",
@@ -20,7 +20,7 @@
20
20
  "publishConfig": {
21
21
  "access": "public"
22
22
  },
23
- "files": ["index.ts", "src/**/*.ts", "README.md", "LICENSE"],
23
+ "files": ["index.ts", "src/**/*.ts", "wordpacks/*.json", "README.md", "LICENSE"],
24
24
  "scripts": {
25
25
  "test": "node --test tests/*.test.ts",
26
26
  "typecheck": "tsc --noEmit"
package/src/flags.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Feature gates for unreleased functionality.
3
+ *
4
+ * Sibling-topping detection and the `/topping-setup` command ship dark: the
5
+ * default below stays `false` until the flow is ready for the public. Setting
6
+ * `PI_TOPPING_SIBLING_SETUP` to any value other than `0`, `false`, `off`, or
7
+ * `no` (case-insensitive) opts a session in without touching code.
8
+ */
9
+
10
+ const SIBLING_SETUP_DEFAULT = false;
11
+
12
+ const FALSEY_FLAG_VALUES = new Set(["0", "false", "off", "no"]);
13
+
14
+ /** Whether the sibling-topping setup flow is registered and advertised. */
15
+ export function isSiblingSetupEnabled(): boolean {
16
+ const value = process.env.PI_TOPPING_SIBLING_SETUP?.trim().toLowerCase();
17
+ if (value === undefined || value === "") return SIBLING_SETUP_DEFAULT;
18
+ return !FALSEY_FLAG_VALUES.has(value);
19
+ }
package/src/menu.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  /**
2
- * menu.ts
3
- *
4
2
  * A reusable, dependency-light box-drawing toggle-menu component for Pi
5
3
  * extensions, built on top of `@earendil-works/pi-tui`'s `Component`
6
4
  * contract and `ctx.ui.custom()` overlay API.
@@ -74,13 +72,13 @@ export interface MenuConfig {
74
72
  * since the menu opened. Lines may contain ANSI styling and are truncated/
75
73
  * padded to fit automatically.
76
74
  *
77
- * Return `string[]` for legacy interval-based animation (see
78
- * `previewIntervalMs`), or a `PreviewResult` to declare the next refresh.
75
+ * Return `string[]` for legacy interval-based animation (defaults to 50ms
76
+ * refresh), or a `PreviewResult` to declare the next refresh interval.
79
77
  * Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
80
78
  */
81
- preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined) => string[] | PreviewResult;
82
- /** Fallback delay in ms for legacy `string[]` previews. Default 50. */
83
- previewIntervalMs?: number;
79
+ preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => string[] | PreviewResult;
80
+ /** Optional heading for the preview block. Defaults to `Preview`. */
81
+ previewTitle?: string;
84
82
  }
85
83
 
86
84
  export interface MenuResult<T> {
@@ -89,9 +87,17 @@ export interface MenuResult<T> {
89
87
  }
90
88
 
91
89
  const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
92
- const MAX_WIDTH = 76;
90
+ const OVERLAY_WIDTH = "86%";
91
+ export const DEFAULT_PREVIEW_WIDTH = 76;
93
92
  const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
94
93
 
94
+ /** Close OSC 8 links truncated by pi-tui's SGR-only truncation reset. */
95
+ function closeTruncatedHyperlink(text: string): string {
96
+ const lastOpen = text.lastIndexOf("\x1b]8;;");
97
+ const lastClose = text.lastIndexOf("\x1b]8;;\x1b\\");
98
+ return lastOpen > lastClose ? `${text}\x1b]8;;\x1b\\` : text;
99
+ }
100
+
95
101
  interface FlatItem {
96
102
  id: string;
97
103
  label: string;
@@ -128,11 +134,11 @@ export class MenuComponent implements Component {
128
134
  private readonly title: string;
129
135
  private readonly sections: MenuSection[];
130
136
  private readonly hints: string[];
137
+ private readonly previewTitle: string;
131
138
  private readonly initialValues: Record<string, MenuValue>;
132
139
  private readonly values: Record<string, MenuValue>;
133
140
  private readonly flat: FlatItem[];
134
141
  private readonly previewFn: MenuConfig["preview"];
135
- private readonly previewIntervalMs: number | undefined;
136
142
  private readonly previewOrigin: number | undefined;
137
143
  private readonly tui: TUI | undefined;
138
144
  private previewTimer: ReturnType<typeof setTimeout> | undefined;
@@ -155,6 +161,7 @@ export class MenuComponent implements Component {
155
161
  this.title = config.title;
156
162
  this.sections = config.sections;
157
163
  this.hints = config.hints ?? DEFAULT_HINTS;
164
+ this.previewTitle = config.previewTitle ?? "Preview";
158
165
  this.tui = tui;
159
166
  this.values = buildInitialValues(config);
160
167
  this.flat = [];
@@ -166,7 +173,6 @@ export class MenuComponent implements Component {
166
173
  this.initialValues = { ...this.values };
167
174
 
168
175
  this.previewFn = config.preview;
169
- this.previewIntervalMs = config.previewIntervalMs;
170
176
  if (this.previewFn) {
171
177
  this.previewOrigin = Date.now();
172
178
  if (tui) {
@@ -286,16 +292,17 @@ export class MenuComponent implements Component {
286
292
  this.invalidate();
287
293
  }
288
294
 
289
- private samplePreview(): string[] | undefined {
295
+ private samplePreview(width: number = DEFAULT_PREVIEW_WIDTH): string[] | undefined {
290
296
  if (!this.previewFn) return undefined;
291
297
 
292
298
  const result = this.previewFn(
293
299
  this.values,
294
300
  this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
295
301
  this.flat[this.cursor]?.id,
302
+ width,
296
303
  );
297
304
  if (Array.isArray(result)) {
298
- this.previewNextRefreshMs = this.previewIntervalMs ?? 50;
305
+ this.previewNextRefreshMs = 50;
299
306
  return result;
300
307
  }
301
308
 
@@ -320,7 +327,7 @@ export class MenuComponent implements Component {
320
327
 
321
328
  private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
322
329
  if (!previewLines?.length) return [];
323
- return [this.renderSectionDivider("Preview", innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
330
+ return [this.renderSectionDivider(this.previewTitle, innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
324
331
  }
325
332
 
326
333
  /** Render every section from `this.flat`, the single source of truth for row order. */
@@ -348,6 +355,12 @@ export class MenuComponent implements Component {
348
355
  const flat = this.flat[index]!;
349
356
  if (flat.sectionIndex !== currentSection) {
350
357
  const remaining = maxRows - lines.length;
358
+ // A section continued from above needs room for a blank separator
359
+ // row, its divider, and at least one item row; otherwise end the
360
+ // window here rather than crowd the divider against the previous
361
+ // section or fold the section into it. The window's first section
362
+ // always renders (a degenerate window may drop its divider).
363
+ if (lines.length > 0 && remaining < 3) break;
351
364
  if (lines.length > 0 && remaining >= 3) lines.push(this.renderBlankRow(innerWidth));
352
365
  if (maxRows - lines.length >= 2) lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
353
366
  currentSection = flat.sectionIndex;
@@ -361,8 +374,7 @@ export class MenuComponent implements Component {
361
374
  }
362
375
 
363
376
  /** Render the scrolling settings body while keeping the selected item in view. */
364
- private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
365
- const allLines = this.buildToggleSections(innerWidth);
377
+ private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
366
378
  if (allLines.length <= maxRows) {
367
379
  this.scrollStart = 0;
368
380
  return allLines;
@@ -374,18 +386,25 @@ export class MenuComponent implements Component {
374
386
  if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
375
387
  this.scrollStart = Math.max(0, Math.min(this.scrollStart, this.flat.length - 1));
376
388
 
377
- let window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
378
- while (window.end < this.cursor && this.scrollStart < this.cursor) {
379
- this.scrollStart++;
380
- window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
381
- }
382
- // Backfill from above whenever more context fits without hiding the cursor.
383
- while (this.scrollStart > 0) {
384
- const candidate = this.buildToggleWindow(innerWidth, this.scrollStart - 1, contentRows);
385
- if (candidate.end < this.cursor) break;
386
- this.scrollStart--;
387
- window = candidate;
389
+ // Walk backward from cursor to find the lowest scrollStart that fits in contentRows.
390
+ {
391
+ let rowsUsed = 2; // 1 for cursor item + 1 for first-section divider
392
+ let section = this.flat[this.cursor]!.sectionIndex;
393
+ this.scrollStart = this.cursor;
394
+ for (let i = this.cursor - 1; i >= 0; i--) {
395
+ const sec = this.flat[i]!.sectionIndex;
396
+ if (sec !== section) {
397
+ if (rowsUsed + 3 > contentRows) break;
398
+ rowsUsed += 3;
399
+ section = sec;
400
+ } else {
401
+ if (rowsUsed + 1 > contentRows) break;
402
+ rowsUsed += 1;
403
+ }
404
+ this.scrollStart = i;
405
+ }
388
406
  }
407
+ const window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
389
408
 
390
409
  const rows = [...window.lines];
391
410
  while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
@@ -403,9 +422,10 @@ export class MenuComponent implements Component {
403
422
  }
404
423
 
405
424
  private buildLines(maxWidth: number, maxRows?: number): string[] {
406
- const previewLines = this.samplePreview();
407
- const boxWidth = Math.max(0, Math.min(MAX_WIDTH, maxWidth));
425
+ const boxWidth = Math.max(0, maxWidth);
408
426
  const innerWidth = Math.max(0, boxWidth - 2);
427
+ // Preview rows are prefixed with a leading space by buildPreviewBlock/renderContentRow.
428
+ const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
409
429
  const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
410
430
  const footer = this.buildFooter(innerWidth);
411
431
  const naturalBody = this.buildToggleSections(innerWidth);
@@ -415,7 +435,7 @@ export class MenuComponent implements Component {
415
435
  }
416
436
 
417
437
  const bodyRows = Math.max(0, maxRows - header.length - footer.length);
418
- const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
438
+ const body = this.buildResponsiveToggleSections(innerWidth, bodyRows, naturalBody);
419
439
  return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
420
440
  }
421
441
 
@@ -476,7 +496,8 @@ export class MenuComponent implements Component {
476
496
 
477
497
  /** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
478
498
  private renderContentRow(content: string, innerWidth: number): string {
479
- const shown = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth) : content;
499
+ const truncated = visibleWidth(content) > innerWidth;
500
+ const shown = truncated ? closeTruncatedHyperlink(truncateToWidth(content, innerWidth)) : content;
480
501
  const pad = Math.max(0, innerWidth - visibleWidth(shown));
481
502
  return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
482
503
  }
@@ -553,6 +574,6 @@ export async function showMenu<T extends Record<string, MenuValue>>(
553
574
  return ctx.ui.custom<MenuResult<T>>(
554
575
  (tui, theme, _keybindings, done) =>
555
576
  new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
556
- { overlay: true, overlayOptions: { width: MAX_WIDTH, maxHeight: "100%" } },
577
+ { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight: "100%" } },
557
578
  );
558
579
  }
@@ -0,0 +1,63 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ export interface PiInstallResult {
4
+ code: number;
5
+ stdout: string;
6
+ stderr: string;
7
+ }
8
+
9
+ const SIGKILL_GRACE_MS = 5_000;
10
+ const EXIT_TIMEOUT = 124;
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> {
14
+ if (!/^npm:@?[\w.-]+(\/[\w.-]+)?$/.test(spec)) {
15
+ return Promise.resolve({ code: 1, stdout: "", stderr: `invalid spec: ${spec}` });
16
+ }
17
+ return new Promise((resolve) => {
18
+ 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"] });
23
+ let stdout = "";
24
+ let stderr = "";
25
+ let settled = false;
26
+ let closed = false;
27
+ let killTimer: ReturnType<typeof setTimeout> | undefined;
28
+
29
+ const finish = (result: PiInstallResult): void => {
30
+ if (settled) return;
31
+ settled = true;
32
+ clearTimeout(timeoutTimer);
33
+ resolve(result);
34
+ };
35
+
36
+ const timeoutTimer = setTimeout(() => {
37
+ processHandle.kill("SIGTERM");
38
+ killTimer = setTimeout(() => {
39
+ if (!closed) processHandle.kill("SIGKILL");
40
+ }, SIGKILL_GRACE_MS);
41
+ finish({
42
+ code: EXIT_TIMEOUT,
43
+ stdout,
44
+ stderr: `${stderr}${stderr ? "\n" : ""}[timed out after ${timeoutMs}ms]`,
45
+ });
46
+ }, timeoutMs);
47
+
48
+ processHandle.stdout?.on("data", (chunk: Buffer) => {
49
+ stdout += chunk.toString();
50
+ });
51
+ processHandle.stderr?.on("data", (chunk: Buffer) => {
52
+ stderr += chunk.toString();
53
+ });
54
+ processHandle.on("error", (error) => {
55
+ finish({ code: 1, stdout, stderr: `${stderr}${stderr ? "\n" : ""}${error.message}` });
56
+ });
57
+ processHandle.on("close", (code) => {
58
+ closed = true;
59
+ if (killTimer !== undefined) clearTimeout(killTimer);
60
+ finish({ code: code ?? 1, stdout, stderr });
61
+ });
62
+ });
63
+ }
package/src/preview.ts CHANGED
@@ -1,28 +1,38 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { PreviewResult } from "./menu.ts";
2
+ import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
3
  import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
4
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";
5
5
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
6
- import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
6
+ import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isDoneMarkerStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
7
+ import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
7
8
  import { pickRandomWord } from "./words.ts";
8
9
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tok/s for the meter,
9
10
  // flat 28 tok/s for the token readouts.
10
- const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28, PREVIEW_WIDTH = 70;
11
+ const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
11
12
  const PROMPT_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "User Prompt").map(entry => entry.id));
12
13
  const MARKER_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "Completion Marker").map(entry => entry.id));
13
14
  function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
14
15
 
15
16
  /** Stateful, per-menu preview renderer that follows the active settings section. */
16
17
  export class PreviewRenderer {
18
+ // Preserve constructor-time randomness so rendering never changes the preview phrase.
17
19
  readonly #word = pickRandomWord();
20
+ readonly #poolFraction = Math.random();
21
+ readonly #packs: readonly WordPack[];
18
22
  readonly #meter = new ActivityMeter();
19
23
  #lastMeterUpdate = 0;
24
+ #cachedPackWord = "";
25
+ #cachedPackSignature = "";
20
26
  readonly #ctx: ExtensionContext;
21
- constructor(ctx: ExtensionContext) { this.#ctx = ctx; }
22
- render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string): PreviewResult {
23
- if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values);
24
- if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values);
25
- if (activeItemId === "useNerdFont") return { lines: [`Icon preview: ${values.useNerdFont ? "" : "π"}`] };
27
+ constructor(ctx: ExtensionContext, packs: readonly WordPack[] = []) {
28
+ this.#ctx = ctx;
29
+ this.#packs = packs;
30
+ }
31
+ render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string, width: number = DEFAULT_PREVIEW_WIDTH): PreviewResult {
32
+ if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
33
+ if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
34
+ if (activeItemId === "useNerdFont") return { lines: ["", `Icon preview: ${values.useNerdFont ? "" : "π"}`, ""] };
35
+ if (activeItemId?.startsWith("pack:")) return this.packPreview(activeItemId.slice("pack:".length), values);
26
36
  let nextRefreshInMs: number | undefined;
27
37
  if (values.shimmer !== false) {
28
38
  nextRefreshInMs = SHIMMER_INTERVAL_MS;
@@ -57,7 +67,14 @@ export class PreviewRenderer {
57
67
  }
58
68
  let word = DEFAULT_WORKING_WORD;
59
69
  if (features.substituteDefaultMessage) {
60
- word = this.#word;
70
+ const packValues = this.packValues(values);
71
+ const signature = JSON.stringify(Object.keys(packValues).sort().map(k => `${k}=${packValues[k]}`));
72
+ if (signature !== this.#cachedPackSignature) {
73
+ const enabledPacks = this.#packs.filter((pack) => isWordPackEnabled(pack.id, packValues));
74
+ this.#cachedPackWord = enabledPacks.length ? selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text : this.#word;
75
+ this.#cachedPackSignature = signature;
76
+ }
77
+ word = this.#cachedPackWord;
61
78
  }
62
79
  let styledWord: string;
63
80
  if (decorations.shimmer) {
@@ -106,13 +123,28 @@ export class PreviewRenderer {
106
123
  tokenRate,
107
124
  }, order);
108
125
  }
109
- private promptPreview(values: Record<string, boolean | string>): PreviewResult {
126
+ private packValues(values: Record<string, boolean | string>): Record<string, boolean> {
127
+ const enabled: Record<string, boolean> = {};
128
+ for (const pack of this.#packs) {
129
+ const value = values[`pack:${pack.id}`];
130
+ if (typeof value === "boolean") enabled[pack.id] = value;
131
+ }
132
+ return enabled;
133
+ }
134
+ private packPreview(id: string, values: Record<string, boolean | string>): PreviewResult {
135
+ const pack = this.#packs.find((candidate) => candidate.id === id);
136
+ if (!pack) return { lines: ["This word pack is unavailable."] };
137
+ const enabled = isWordPackEnabled(id, this.packValues(values));
138
+ const source = pack.bundled ? "Shipped example" : `Custom: ${wordPacksPath()}`;
139
+ return { lines: [`${pack.name}: ${enabled ? "enabled" : "disabled"}`, `${pack.words.length} phrases · ${source}`, ...(pack.description ? [pack.description] : [])] };
140
+ }
141
+ private promptPreview(values: Record<string, boolean | string>, width: number): PreviewResult {
110
142
  if (values.decorateUserPrompt !== true) return { lines: ["User prompt decoration is disabled."] };
111
143
 
112
144
  const borderColor = values.borderColor;
113
145
  const borderStyle = values.borderStyle;
114
146
  const timestamp = values.promptTimestamp === true ? Date.now() : undefined;
115
- const lines = buildPromptBoxLines("ping", timestamp, PREVIEW_WIDTH, this.#ctx.ui.theme, {
147
+ const lines = buildPromptBoxLines("ping", timestamp, width, this.#ctx.ui.theme, {
116
148
  showIcon: values.promptIcon === true,
117
149
  showTimestamp: values.promptTimestamp === true,
118
150
  showProvider: values.promptProvider === true,
@@ -127,7 +159,7 @@ export class PreviewRenderer {
127
159
  ? { lines }
128
160
  : { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
129
161
  }
130
- private markerPreview(values: Record<string, boolean | string>): PreviewResult {
162
+ private markerPreview(values: Record<string, boolean | string>, width: number): PreviewResult {
131
163
  if (values.doneMarker !== true) return { lines: ["Completion marker is disabled."] };
132
164
  const theme = this.#ctx.ui.theme;
133
165
  const icon = values.doneMarkerIcon === true ? theme.fg("text", values.useNerdFont === true ? "" : "π") : "";
@@ -138,7 +170,8 @@ export class PreviewRenderer {
138
170
  const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details);
139
171
  const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
140
172
  const borderColor = isSettingColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
141
- const marker = buildCompletionMarkerLine(content, PREVIEW_WIDTH, theme, borderStyle, borderColor);
173
+ const markerStyle = isDoneMarkerStyle(values.doneMarkerStyle) ? values.doneMarkerStyle : DEFAULT_SETTINGS.decorations.doneMarkerStyle;
174
+ const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle);
142
175
  return { lines: ["", marker, ""] };
143
176
  }
144
177
  }
@@ -1,6 +1,7 @@
1
1
  import type { MessageRenderer, ThemeColor } 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 SettingColor } from "./settings.ts";
3
+ import { DEFAULT_SETTINGS, isBorderStyle, isSettingColor, type BorderStyle, type DoneMarkerBorderStyle, type DoneMarkerStyle, type SettingColor } from "./settings.ts";
4
+ import { stripControlChars } from "./util.ts";
4
5
 
5
6
  export const PROMPT_BOX_TYPE = "pi-topping-prompt";
6
7
 
@@ -32,11 +33,6 @@ type PromptTheme = { fg(color: string, text: string): string };
32
33
 
33
34
  const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
34
35
 
35
- /** Strip control and formatting characters (bidi overrides, zero-width joiners, etc.) from untrusted single-line text. */
36
- export function stripControlChars(text: string): string {
37
- return text.replace(/[\p{Cc}\p{Cf}]/gu, "");
38
- }
39
-
40
36
  /** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary. */
41
37
  export function buildCompletionMarkerContent(
42
38
  theme: PromptTheme,
@@ -56,6 +52,7 @@ export function buildCompletionMarkerLine(
56
52
  theme: PromptTheme,
57
53
  borderStyle: DoneMarkerBorderStyle,
58
54
  borderColor: SettingColor,
55
+ markerStyle: DoneMarkerStyle = DEFAULT_SETTINGS.decorations.doneMarkerStyle,
59
56
  ): string {
60
57
  const safeWidth = Math.max(0, Math.floor(width));
61
58
  if (safeWidth === 0) return "";
@@ -63,8 +60,12 @@ export function buildCompletionMarkerLine(
63
60
 
64
61
  const g = BORDER_GLYPHS[borderStyle];
65
62
  const border = (text: string) => theme.fg(borderColor, text);
66
- const trail = ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.h.repeat(length)).join(" ")}`;
67
- const line = `${border(`${g.bl}${g.h.repeat(2)} `)}${content}${border(trail)}`;
63
+ const prefix = `${g.bl}${g.h.repeat(2)} `;
64
+
65
+ const suffix = markerStyle === "bookend"
66
+ ? ` ${g.h.repeat(Math.max(0, safeWidth - visibleWidth(prefix) - visibleWidth(content) - 2))}${g.br}`
67
+ : ` ${COMPLETION_MARKER_TRAIL_RUNS.map(length => g.h.repeat(length)).join(" ")}`;
68
+ const line = `${border(prefix)}${content}${border(suffix)}`;
68
69
  return visibleWidth(line) > safeWidth ? truncateToWidth(line, safeWidth) : line;
69
70
  }
70
71