@underactive/pi-topping 0.6.1 → 0.6.3

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,7 +6,7 @@ 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, 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.
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. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
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
 
@@ -71,6 +71,12 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
71
71
  ║ [■] Mid-turn inputs ON ║
72
72
  ║ ║
73
73
  ╟─ Word Packs ───────────────────────────────────────────────╢
74
+ ║ [ ] Doctor Who OFF ║
75
+ ║ [ ] Firefly OFF ║
76
+ ║ [ ] Hitchhiker's Guide OFF ║
77
+ ║ [ ] The Lord of the Rings OFF ║
78
+ ║ [ ] The Matrix OFF ║
79
+ ║ [ ] Portal OFF ║
74
80
  ║ [ ] SimCity OFF ║
75
81
  ║ [ ] Star Trek OFF ║
76
82
  ║ [ ] Star Wars OFF ║
@@ -78,15 +84,16 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
78
84
  ╟─ Options ──────────────────────────────────────────────────╢
79
85
  ║ [■] Use NerdFont icons ON ║
80
86
  ╟────────────────────────────────────────────────────────────╢
81
- ║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
82
- ╚════════════════════════════════════════════════════[ 9/41 ]╝
87
+ ║ ↑↓ move PgUp/PgDn page ←→ select ║
88
+ ║ ␣ toggle ⏎ apply esc cancel ║
89
+ ╚════════════════════════════════════════════════════[ 9/47 ]╝
83
90
  ```
84
91
 
85
92
  ### Word packs
86
93
 
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`.
94
+ Base activity words are always available. All word packs, including the shipped **Doctor Who**, **Firefly**, **Hitchhiker's Guide**, **The Lord of the Rings**, **The Matrix**, **Portal**, **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`.
88
95
 
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:
96
+ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.json`, `wordpacks/firefly.json`, `wordpacks/hitchhikers-guide.json`, `wordpacks/lord-of-the-rings.json`, `wordpacks/matrix.json`, `wordpacks/portal.json`, `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 `doctor-who`, `firefly`, `hitchhikers-guide`, `lord-of-the-rings`, `matrix`, `portal`, `simcity`, `star-trek`, and `star-wars` IDs are reserved), name, and words:
90
97
 
91
98
  ```json
92
99
  {
@@ -122,7 +129,7 @@ Restart Pi (or run `/reload`) to pick it up.
122
129
 
123
130
  ## Requirements
124
131
 
125
- - [Pi](https://github.com/earendil-works/pi-coding-agent)
132
+ - [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.
126
133
  - Node.js ≥22.19.0
127
134
 
128
135
  ## Development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",
@@ -30,8 +30,8 @@
30
30
  "@earendil-works/pi-tui": "*"
31
31
  },
32
32
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.82.1",
34
- "@earendil-works/pi-tui": "0.82.1",
33
+ "@earendil-works/pi-coding-agent": "0.84.4",
34
+ "@earendil-works/pi-tui": "0.84.4",
35
35
  "@types/node": "^24.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },
package/src/menu.ts CHANGED
@@ -5,27 +5,14 @@
5
5
  *
6
6
  * Renders a titled box containing one or more sections of boolean toggle,
7
7
  * multi-value cycle, or drag-to-reorder items, plus an optional live-updating
8
- * preview section driven by a caller-supplied render callback, e.g.:
9
- *
10
- * ╔═[ Pi Topping: Settings ]════════════════════════╗
11
- * ╟─ Preview ────────────────────────────────────╢
12
- * ║ ║
13
- * ║ ⠋ Cerebrating… ⣤⣤⣤⣤ 28 tok/s · 3s · ↓ 84 tokens ║
14
- * ║ ║
15
- * ╟─ Decorations ───────────────────────────────────╢
16
- * ║ ❯ [■] Animated spinner ON ║
17
- * ║ [ ] "Working..." text shimmer OFF ║
18
- * ║ ║
19
- * ╟──────────────────────────────────────────────────╢
20
- * ║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
21
- * ╚══════════════════════════════════════════[ 1/2 ]═╝
8
+ * preview section driven by a caller-supplied render callback.
22
9
  *
23
10
  * Intended to be reused by any extension that needs a simple modal toggle
24
11
  * menu; it has no dependency on this extension's own settings shape.
25
12
  */
26
13
 
27
14
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
28
- import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { type Component, Key, matchesKey, truncateToWidth, type SizeValue, type TUI, visibleWidth } from "@earendil-works/pi-tui";
29
16
 
30
17
  export type MenuValue = boolean | string;
31
18
 
@@ -79,6 +66,8 @@ export interface MenuConfig {
79
66
  preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => string[] | PreviewResult;
80
67
  /** Optional heading for the preview block. Defaults to `Preview`. */
81
68
  previewTitle?: string;
69
+ /** Maximum overlay height in rows or as a percentage of the terminal height. */
70
+ maxHeight?: SizeValue;
82
71
  }
83
72
 
84
73
  export interface MenuResult<T> {
@@ -86,7 +75,7 @@ export interface MenuResult<T> {
86
75
  values: T;
87
76
  }
88
77
 
89
- const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
78
+ const DEFAULT_HINTS = ["\u2191\u2193 move", "PgUp/PgDn page", "\u2423 toggle", "\u23ce apply", "esc cancel"];
90
79
  const OVERLAY_WIDTH = "86%";
91
80
  export const DEFAULT_PREVIEW_WIDTH = 76;
92
81
  const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
@@ -141,6 +130,7 @@ export class MenuComponent implements Component {
141
130
  private readonly previewFn: MenuConfig["preview"];
142
131
  private readonly previewOrigin: number | undefined;
143
132
  private readonly tui: TUI | undefined;
133
+ private readonly maxHeight: SizeValue | undefined;
144
134
  private previewTimer: ReturnType<typeof setTimeout> | undefined;
145
135
  private previewNextRefreshMs: number | undefined;
146
136
  private disposed = false;
@@ -149,6 +139,7 @@ export class MenuComponent implements Component {
149
139
  private cachedWidth: number | undefined;
150
140
  private cachedRows: number | undefined;
151
141
  private cachedLines: string[] | undefined;
142
+ private pageItemCount: number | undefined;
152
143
 
153
144
  constructor(
154
145
  config: MenuConfig,
@@ -163,6 +154,7 @@ export class MenuComponent implements Component {
163
154
  this.hints = config.hints ?? DEFAULT_HINTS;
164
155
  this.previewTitle = config.previewTitle ?? "Preview";
165
156
  this.tui = tui;
157
+ this.maxHeight = config.maxHeight;
166
158
  this.values = buildInitialValues(config);
167
159
  this.flat = [];
168
160
  for (const [sectionIndex, section] of config.sections.entries()) {
@@ -203,7 +195,7 @@ export class MenuComponent implements Component {
203
195
 
204
196
  // Map input to a normalized key name.
205
197
  let mappedKey: string | undefined;
206
- for (const k of [Key.up, Key.down, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
198
+ for (const k of [Key.up, Key.down, Key.pageUp, Key.pageDown, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
207
199
  if (matchesKey(data, k)) {
208
200
  mappedKey = k;
209
201
  break;
@@ -214,14 +206,16 @@ export class MenuComponent implements Component {
214
206
  const keyActions: Record<string, () => void> = {
215
207
  [Key.up]: () => {
216
208
  if (this.moveGrabbedItem(-1)) return;
217
- this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
209
+ this.cursor = Math.max(0, this.cursor - 1);
218
210
  this.invalidate();
219
211
  },
220
212
  [Key.down]: () => {
221
213
  if (this.moveGrabbedItem(1)) return;
222
- this.cursor = (this.cursor + 1) % this.flat.length;
214
+ this.cursor = Math.min(this.flat.length - 1, this.cursor + 1);
223
215
  this.invalidate();
224
216
  },
217
+ [Key.pageUp]: () => this.moveByPage(-1),
218
+ [Key.pageDown]: () => this.moveByPage(1),
225
219
  [Key.left]: () => this.cycleCurrentValue(-1),
226
220
  [Key.right]: () => this.cycleCurrentValue(1),
227
221
  [Key.space]: () => {
@@ -258,9 +252,9 @@ export class MenuComponent implements Component {
258
252
  }
259
253
 
260
254
  /**
261
- * Move the grabbed reorder row one slot within its group, clamped at the group
262
- * edges. Returns true when the key belongs to the mover and must not also move
263
- * the cursor — which is what keeps a grab from escaping its group.
255
+ * Move the grabbed reorder row within its group, clamped at the group edges.
256
+ * Returns true when the key belongs to the mover and must not also move the
257
+ * cursor — which is what keeps a grab from escaping its group.
264
258
  */
265
259
  private moveGrabbedItem(delta: number): boolean {
266
260
  const grabbed = this.flat[this.cursor]!;
@@ -272,10 +266,10 @@ export class MenuComponent implements Component {
272
266
  let end = this.cursor;
273
267
  while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
274
268
 
275
- const target = this.cursor + delta;
276
- if (target >= start && target <= end) {
277
- this.flat[this.cursor] = this.flat[target]!;
278
- this.flat[target] = grabbed;
269
+ const target = Math.max(start, Math.min(end, this.cursor + delta));
270
+ if (target !== this.cursor) {
271
+ this.flat.splice(this.cursor, 1);
272
+ this.flat.splice(target, 0, grabbed);
279
273
  this.cursor = target;
280
274
  this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
281
275
  this.invalidate();
@@ -283,6 +277,20 @@ export class MenuComponent implements Component {
283
277
  return true;
284
278
  }
285
279
 
280
+ private moveByPage(direction: -1 | 1): void {
281
+ const pageItemCount = this.getPageItemCount();
282
+ if (this.moveGrabbedItem(direction * pageItemCount)) return;
283
+ this.cursor = Math.max(0, Math.min(this.flat.length - 1, this.cursor + direction * pageItemCount));
284
+ this.invalidate();
285
+ }
286
+
287
+ private getPageItemCount(): number {
288
+ if (this.pageItemCount !== undefined) return this.pageItemCount;
289
+ const rows = this.availableRows();
290
+ if (rows === undefined) return Math.max(1, Math.min(this.flat.length, 10));
291
+ return Math.max(1, rows - 6);
292
+ }
293
+
286
294
  private cycleCurrentValue(delta: number): void {
287
295
  const item = this.flat[this.cursor]!;
288
296
  if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
@@ -377,10 +385,17 @@ export class MenuComponent implements Component {
377
385
  private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
378
386
  if (allLines.length <= maxRows) {
379
387
  this.scrollStart = 0;
388
+ this.pageItemCount = Math.max(1, this.flat.length);
380
389
  return allLines;
381
390
  }
382
- if (maxRows <= 0) return [];
383
- if (maxRows === 1) return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
391
+ if (maxRows <= 0) {
392
+ this.pageItemCount = 1;
393
+ return [];
394
+ }
395
+ if (maxRows === 1) {
396
+ this.pageItemCount = 1;
397
+ return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
398
+ }
384
399
 
385
400
  const contentRows = maxRows - 1; // Reserve one fixed row for scroll status.
386
401
  if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
@@ -405,6 +420,7 @@ export class MenuComponent implements Component {
405
420
  }
406
421
  }
407
422
  const window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
423
+ this.pageItemCount = Math.max(1, window.end - this.scrollStart + 1);
408
424
 
409
425
  const rows = [...window.lines];
410
426
  while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
@@ -417,8 +433,16 @@ export class MenuComponent implements Component {
417
433
  }
418
434
 
419
435
  private availableRows(): number | undefined {
420
- const rows = (this.tui as (TUI & { terminal?: { rows?: number } }) | undefined)?.terminal?.rows;
421
- return typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : undefined;
436
+ const rows = this.tui?.terminal?.rows;
437
+ if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) return undefined;
438
+
439
+ const terminalRows = Math.floor(rows);
440
+ if (this.maxHeight === undefined) return terminalRows;
441
+ const requestedRows = typeof this.maxHeight === "number"
442
+ ? this.maxHeight
443
+ : (Number.parseFloat(this.maxHeight) / 100) * terminalRows;
444
+ if (!Number.isFinite(requestedRows)) return terminalRows;
445
+ return Math.max(1, Math.min(terminalRows, Math.floor(requestedRows)));
422
446
  }
423
447
 
424
448
  private buildLines(maxWidth: number, maxRows?: number): string[] {
@@ -431,6 +455,7 @@ export class MenuComponent implements Component {
431
455
  const naturalBody = this.buildToggleSections(innerWidth);
432
456
  const naturalHeight = header.length + naturalBody.length + footer.length;
433
457
  if (maxRows === undefined || naturalHeight <= maxRows) {
458
+ this.pageItemCount = Math.max(1, this.flat.length);
434
459
  return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
435
460
  }
436
461
 
@@ -571,9 +596,10 @@ export async function showMenu<T extends Record<string, MenuValue>>(
571
596
  return { applied: false, values: initialValues };
572
597
  }
573
598
 
599
+ const maxHeight = config.maxHeight ?? "100%";
574
600
  return ctx.ui.custom<MenuResult<T>>(
575
601
  (tui, theme, _keybindings, done) =>
576
602
  new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
577
- { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight: "100%" } },
603
+ { overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight } },
578
604
  );
579
605
  }
package/src/session.ts CHANGED
@@ -47,8 +47,20 @@ type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
47
47
  type MessageUpdateEvent = Extract<ExtensionEvent, { type: "message_update" }>;
48
48
  type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
49
49
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
50
+ type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
51
+ type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
52
+ type UIPromptKind = UIPromptStartEvent["kind"];
50
53
 
51
54
  const DONE_ENTRY_TYPE = "pi-topping-done";
55
+ const WAITING_LABELS: Record<UIPromptKind, string> = {
56
+ select: "Waiting for selection",
57
+ confirm: "Waiting for confirmation",
58
+ input: "Waiting for input",
59
+ editor: "Waiting in editor",
60
+ custom: "Waiting for input",
61
+ };
62
+ const WAITING_PULSE_FRAMES = ["·", "•", "●", "•"];
63
+ const WAITING_PULSE_INTERVAL_MS = 120;
52
64
  // Reconciliation resets the EMA at message boundaries, so retain and then fade the last rate to avoid flicker.
53
65
  const TOKEN_RATE_HOLD_MS = 1_500;
54
66
  const TOKEN_RATE_FADE_MS = 250;
@@ -74,6 +86,7 @@ interface SessionState {
74
86
  tokenRateFadeStartsAt: number;
75
87
  timer: ReturnType<typeof setInterval> | null;
76
88
  busy: boolean;
89
+ waiting: { kind: UIPromptKind; title?: string } | null;
77
90
  midTurnInputs: number;
78
91
  /** Last string passed to setWorkingMessage(), or the NOT_SENT sentinel before the first call. */
79
92
  lastMessage: string | undefined | typeof NOT_SENT;
@@ -106,11 +119,17 @@ function makeFreshState(): SessionState {
106
119
  tokenRateFadeStartsAt: 0,
107
120
  timer: null,
108
121
  busy: false,
122
+ waiting: null,
109
123
  midTurnInputs: 0,
110
124
  lastMessage: NOT_SENT,
111
125
  };
112
126
  }
113
127
 
128
+ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
129
+ const title = waiting.title ? stripControlChars(waiting.title).trim() : "";
130
+ return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
131
+ }
132
+
114
133
  /** Owns mutable extension state and Pi lifecycle registrations. */
115
134
  export class SessionManager {
116
135
  #counter = new StreamingWordCounter();
@@ -225,6 +244,23 @@ export class SessionManager {
225
244
  }
226
245
  };
227
246
 
247
+ #onUIPromptStart = (event: UIPromptStartEvent, ctx: ExtensionContext): void => {
248
+ this.#currentCtx = ctx;
249
+ if (!this.usable(ctx) || !this.#state.busy) return;
250
+ this.#state.waiting = { kind: event.kind, title: event.title };
251
+ this.applyWaitingIndicator(ctx);
252
+ this.tick();
253
+ };
254
+
255
+ #onUIPromptEnd = (_event: UIPromptEndEvent, ctx: ExtensionContext): void => {
256
+ this.#currentCtx = ctx;
257
+ if (!this.usable(ctx) || !this.#state.waiting) return;
258
+ this.#state.waiting = null;
259
+ if (!this.#state.busy) return;
260
+ this.applyIndicator(ctx);
261
+ this.tick();
262
+ };
263
+
228
264
  #onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
229
265
  this.#currentCtx = ctx;
230
266
  if (!this.usable(ctx)) return;
@@ -232,6 +268,7 @@ export class SessionManager {
232
268
  const hadPrompt = !!this.#state.startTime;
233
269
  const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
234
270
  this.#state.busy = false;
271
+ this.#state.waiting = null;
235
272
  this.#state.startTime = 0;
236
273
  this.#counter.reset();
237
274
  this.stopTimer();
@@ -252,6 +289,7 @@ export class SessionManager {
252
289
  };
253
290
 
254
291
  #onSessionShutdown = async (_e: SessionShutdownEvent, _ctx: ExtensionContext): Promise<void> => {
292
+ this.#state.waiting = null;
255
293
  this.stopTimer();
256
294
  };
257
295
 
@@ -298,6 +336,8 @@ export class SessionManager {
298
336
  this.#pi.on("message_end", this.#onMessageEnd);
299
337
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
300
338
  this.#pi.on("agent_settled", this.#onAgentSettled);
339
+ this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
340
+ this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
301
341
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
302
342
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
303
343
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
@@ -345,6 +385,13 @@ export class SessionManager {
345
385
  return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
346
386
  }
347
387
 
388
+ private applyWaitingIndicator(ctx: ExtensionContext): void {
389
+ ctx.ui.setWorkingIndicator({
390
+ frames: WAITING_PULSE_FRAMES.map((frame) => ctx.ui.theme.fg("dim", frame)),
391
+ intervalMs: WAITING_PULSE_INTERVAL_MS,
392
+ });
393
+ }
394
+
348
395
  private applyIndicator(ctx: ExtensionContext): void {
349
396
  if (!this.#settings.decorations.animatedSpinner || this.spinnerInMessage()) {
350
397
  ctx.ui.setWorkingIndicator({ frames: [] });
@@ -401,6 +448,14 @@ export class SessionManager {
401
448
  const ctx = this.#currentCtx;
402
449
  const state = this.#state;
403
450
  if (!state.busy || !ctx) return;
451
+ if (state.waiting) {
452
+ const msg = ctx.ui.theme.fg("dim", waitingLabel(state.waiting));
453
+ if (msg !== state.lastMessage) {
454
+ state.lastMessage = msg;
455
+ ctx.ui.setWorkingMessage(msg);
456
+ }
457
+ return;
458
+ }
404
459
 
405
460
  const now = Date.now();
406
461
  const total = state.confirmTokens + state.liveTokens;
@@ -497,8 +552,9 @@ export class SessionManager {
497
552
  const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
498
553
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
499
554
  title: "Pi Topping: Settings",
555
+ maxHeight: "75%",
500
556
  sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
501
- hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
557
+ hints: ["↑↓ move", "PgUp/PgDn page", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
502
558
  preview: preview.render.bind(preview),
503
559
  });
504
560
  if (!result.applied) return;
@@ -512,7 +568,10 @@ export class SessionManager {
512
568
  }
513
569
 
514
570
  this.#settings = updatedSettings;
515
- if (before !== this.indicatorFingerprint()) this.applyIndicator(ctx);
571
+ if (before !== this.indicatorFingerprint()) {
572
+ if (this.#state.waiting) this.applyWaitingIndicator(ctx);
573
+ else this.applyIndicator(ctx);
574
+ }
516
575
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
517
576
  if (this.#state.busy) {
518
577
  this.stopTimer();
@@ -114,7 +114,7 @@ export function registerSetupCommand(pi: ExtensionAPI, install: ToppingInstaller
114
114
  })),
115
115
  },
116
116
  ],
117
- hints: ["↑↓ move", "␣ toggle", "⏎ install", "esc cancel"],
117
+ hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ install", "esc cancel"],
118
118
  previewTitle: "Description",
119
119
  preview: (_values, _elapsedMs, activeItemId) => ({ lines: renderDescription(missing, activeItemId) }),
120
120
  });
package/src/word-packs.ts CHANGED
@@ -6,7 +6,7 @@ import { isPlainObject, stripControlChars } from "./util.ts";
6
6
  import { WORDS, type WordEntry } from "./words.ts";
7
7
 
8
8
  const PACK_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
9
- const RESERVED_BUNDLED_PACK_IDS = new Set(["simcity", "star-trek", "star-wars"]);
9
+ const RESERVED_BUNDLED_PACK_IDS = new Set(["doctor-who", "firefly", "hitchhikers-guide", "lord-of-the-rings", "matrix", "portal", "simcity", "star-trek", "star-wars"]);
10
10
 
11
11
  export function isWordPackId(value: string): boolean { return PACK_ID_PATTERN.test(value); }
12
12
  const BUNDLED_PACK_DIRECTORY = fileURLToPath(new URL("../wordpacks/", import.meta.url));