@underactive/pi-topping 0.5.1 → 0.6.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
@@ -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.0",
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. */
@@ -361,8 +368,7 @@ export class MenuComponent implements Component {
361
368
  }
362
369
 
363
370
  /** 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);
371
+ private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
366
372
  if (allLines.length <= maxRows) {
367
373
  this.scrollStart = 0;
368
374
  return allLines;
@@ -403,9 +409,10 @@ export class MenuComponent implements Component {
403
409
  }
404
410
 
405
411
  private buildLines(maxWidth: number, maxRows?: number): string[] {
406
- const previewLines = this.samplePreview();
407
- const boxWidth = Math.max(0, Math.min(MAX_WIDTH, maxWidth));
412
+ const boxWidth = Math.max(0, maxWidth);
408
413
  const innerWidth = Math.max(0, boxWidth - 2);
414
+ // Preview rows are prefixed with a leading space by buildPreviewBlock/renderContentRow.
415
+ const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
409
416
  const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
410
417
  const footer = this.buildFooter(innerWidth);
411
418
  const naturalBody = this.buildToggleSections(innerWidth);
@@ -415,7 +422,7 @@ export class MenuComponent implements Component {
415
422
  }
416
423
 
417
424
  const bodyRows = Math.max(0, maxRows - header.length - footer.length);
418
- const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
425
+ const body = this.buildResponsiveToggleSections(innerWidth, bodyRows, naturalBody);
419
426
  return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
420
427
  }
421
428
 
@@ -476,7 +483,8 @@ export class MenuComponent implements Component {
476
483
 
477
484
  /** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
478
485
  private renderContentRow(content: string, innerWidth: number): string {
479
- const shown = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth) : content;
486
+ const truncated = visibleWidth(content) > innerWidth;
487
+ const shown = truncated ? closeTruncatedHyperlink(truncateToWidth(content, innerWidth)) : content;
480
488
  const pad = Math.max(0, innerWidth - visibleWidth(shown));
481
489
  return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
482
490
  }
@@ -553,6 +561,6 @@ export async function showMenu<T extends Record<string, MenuValue>>(
553
561
  return ctx.ui.custom<MenuResult<T>>(
554
562
  (tui, theme, _keybindings, done) =>
555
563
  new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
556
- { overlay: true, overlayOptions: { width: MAX_WIDTH, maxHeight: "100%" } },
564
+ { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight: "100%" } },
557
565
  );
558
566
  }
@@ -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
 
package/src/session.ts CHANGED
@@ -33,11 +33,15 @@ import {
33
33
  TOKEN_RATE_PLACEHOLDER,
34
34
  StreamingWordCounter,
35
35
  } from "./format.ts";
36
+ import { isSiblingSetupEnabled } from "./flags.ts";
36
37
  import { showMenu } from "./menu.ts";
38
+ import { registerSetupCommand } from "./setup-command.ts";
37
39
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type SettingColor } from "./settings.ts";
40
+ import { notifyMissingToppingsOnce } from "./toppings.ts";
38
41
  import { PreviewRenderer } from "./preview.ts";
39
- import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, stripControlChars, type PromptBoxDetails } from "./prompt-decorator.ts";
40
- import { pickRandomWord, pickRawWord } from "./words.ts";
42
+ import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
43
+ import { stripControlChars } from "./util.ts";
44
+ import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
41
45
 
42
46
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
43
47
  type MessageUpdateEvent = Extract<ExtensionEvent, { type: "message_update" }>;
@@ -58,7 +62,7 @@ interface DoneEntryData {
58
62
 
59
63
  interface SessionState {
60
64
  startTime: number;
61
- currentWord: string;
65
+ workingText: WorkingTextSelection;
62
66
  confirmTokens: number;
63
67
  liveTokens: number;
64
68
  shimmerOrigin: number;
@@ -90,7 +94,7 @@ function resetTokenRateState(state: SessionState): void {
90
94
  function makeFreshState(): SessionState {
91
95
  return {
92
96
  startTime: 0,
93
- currentWord: "",
97
+ workingText: { text: "", pastTense: "Worked" },
94
98
  confirmTokens: 0,
95
99
  liveTokens: 0,
96
100
  shimmerOrigin: 0,
@@ -112,6 +116,8 @@ export class SessionManager {
112
116
  #counter = new StreamingWordCounter();
113
117
  #state = makeFreshState();
114
118
  #settings = loadSettings();
119
+ #userPacks: WordPack[] = [];
120
+ #bundledPacks = loadBundledWordPacks();
115
121
  #currentCtx: ExtensionContext | null = null;
116
122
  readonly #pi: ExtensionAPI;
117
123
 
@@ -124,9 +130,13 @@ export class SessionManager {
124
130
  this.#counter.reset();
125
131
  this.#state = makeFreshState();
126
132
  this.#settings = loadSettings();
133
+ this.#userPacks = loadUserWordPacks();
127
134
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
128
135
  this.#currentCtx = ctx;
129
- if (this.usable(ctx)) this.applyIndicator(ctx);
136
+ if (this.usable(ctx)) {
137
+ this.applyIndicator(ctx);
138
+ if (isSiblingSetupEnabled()) notifyMissingToppingsOnce(this.#pi, ctx.ui);
139
+ }
130
140
  };
131
141
 
132
142
  #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
@@ -209,7 +219,7 @@ export class SessionManager {
209
219
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
210
220
  this.#currentCtx = ctx;
211
221
  if (this.usable(ctx)) {
212
- this.#state.currentWord = pickRandomWord();
222
+ this.#state.workingText = this.pickWorkingWord();
213
223
  this.#state.shimmerOrigin = Date.now();
214
224
  this.tick();
215
225
  }
@@ -232,7 +242,7 @@ export class SessionManager {
232
242
  this.#state.lastMessage = NOT_SENT;
233
243
  if (this.#settings.features.doneMarker && hadPrompt) {
234
244
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
235
- word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
245
+ word: this.#settings.features.randomizeDoneMarker ? this.#state.workingText.pastTense : "Worked",
236
246
  elapsedMs,
237
247
  tokens: this.#state.confirmTokens,
238
248
  midTurnInputs: this.#state.midTurnInputs,
@@ -259,6 +269,7 @@ export class SessionManager {
259
269
  const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details);
260
270
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
261
271
  const borderColor = this.#settings.decorations.doneMarkerBorderColor;
272
+ const markerStyle = this.#settings.decorations.doneMarkerStyle;
262
273
  if (borderStyle === "none") return new Text(markerContent);
263
274
 
264
275
  let cachedWidth: number | undefined;
@@ -267,7 +278,7 @@ export class SessionManager {
267
278
  render(width: number): string[] {
268
279
  if (cachedWidth !== width) {
269
280
  cachedWidth = width;
270
- cachedLines = ["", ` ${buildCompletionMarkerLine(markerContent, width - 2, theme, borderStyle, borderColor)}`, ""];
281
+ cachedLines = ["", buildCompletionMarkerLine(markerContent, width, theme, borderStyle, borderColor, markerStyle), ""];
271
282
  }
272
283
  return cachedLines!;
273
284
  },
@@ -294,6 +305,7 @@ export class SessionManager {
294
305
  description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
295
306
  handler: async (_a, ctx) => this.showSettings(ctx),
296
307
  });
308
+ if (isSiblingSetupEnabled()) registerSetupCommand(this.#pi);
297
309
  }
298
310
 
299
311
  private usable(ctx: ExtensionContext): boolean {
@@ -367,11 +379,15 @@ export class SessionManager {
367
379
  if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
368
380
  }
369
381
 
382
+ private pickWorkingWord(): WorkingTextSelection {
383
+ return pickWorkingTextSelection(this.#settings.wordPacks, [...this.#bundledPacks, ...this.#userPacks]);
384
+ }
385
+
370
386
  private resetTurn(now: number): void {
371
387
  const state = this.#state;
372
388
  state.startTime = now;
373
389
  state.shimmerOrigin = now;
374
- state.currentWord = pickRandomWord();
390
+ state.workingText = this.pickWorkingWord();
375
391
  state.confirmTokens = 0;
376
392
  state.liveTokens = 0;
377
393
  state.activityMeter.reset();
@@ -413,7 +429,7 @@ export class SessionManager {
413
429
  return;
414
430
  }
415
431
 
416
- const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
432
+ const word = features.substituteDefaultMessage ? state.workingText.text : DEFAULT_WORKING_WORD;
417
433
  const styled = decorations.shimmer
418
434
  ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
419
435
  : ctx.ui.theme.fg("text", word);
@@ -476,11 +492,12 @@ export class SessionManager {
476
492
  return;
477
493
  }
478
494
 
495
+ this.#userPacks = loadUserWordPacks();
479
496
  const before = this.indicatorFingerprint();
480
- const preview = new PreviewRenderer(ctx);
497
+ const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
481
498
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
482
499
  title: "Pi Topping: Settings",
483
- sections: buildMenuSections(this.#settings),
500
+ sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
484
501
  hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
485
502
  preview: preview.render.bind(preview),
486
503
  });