@underactive/pi-topping 0.8.0 → 0.8.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
@@ -100,17 +100,20 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
100
100
  ║ [ ] Star Trek OFF ║
101
101
  ║ [ ] Star Wars OFF ║
102
102
  ║ ║
103
+ ║ [■] Include default/random “Working” text ON ║
104
+ ║ ║
103
105
  ╟─ Options ─────────────────────────────────────────────────────╢
104
106
  ║ [■] Use NerdFont icons ON ║
107
+ ║ [ ] Response model footer OFF ║
105
108
  ╟───────────────────────────────────────────────────────────────╢
106
109
  ║ ↑↓ move PgUp/PgDn page ←→ select ║
107
110
  ║ ␣ toggle ⏎ apply esc cancel ║
108
- ╚═══════════════════════════════════════════════════════[ 9/54 ]╝
111
+ ╚═══════════════════════════════════════════════════════[ 9/56 ]╝
109
112
  ```
110
113
 
111
114
  ### Word packs
112
115
 
113
- 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`.
116
+ Base activity words are included by default. 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. With at least one pack enabled, turn off **Include default/random “Working” text** to draw only from enabled packs; with none enabled the option is locked on. When Randomize “Worked” text is on, the completion marker uses the selected entry’s matching past tense; otherwise it uses `Worked`.
114
117
 
115
118
  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:
116
119
 
@@ -141,7 +144,7 @@ full brightness for 1.5 seconds, then fades through five theme-aware shades to t
141
144
  the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
142
145
  restarts the cycle.
143
146
 
144
- The response model is captured from assistant responses, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. Equivalent local paths and decorated filenames are suppressed. It can be hidden, recolored, permanently dimmed, or reordered. When the active provider is `switchyard`, the loader and its post-settlement hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies. After the agent settles, its final value remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears; new work cancels the pending fade.
147
+ The response model is captured from assistant responses, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
145
148
 
146
149
  ## Requirements
147
150
 
@@ -157,7 +160,7 @@ npm test
157
160
  npm run typecheck
158
161
  ```
159
162
 
160
- Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence and schema migrations, word-pack parsing and loading, the `menu.ts` component, the settings menu wiring end to end, and the flag-gated `/topping-setup` command's install and uninstall flows.
163
+ Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence and schema migrations, word-pack parsing, loading, and default-text selection, response-model loader and footer behavior, the `menu.ts` component, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
161
164
 
162
165
  ## Limitations
163
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -12,7 +12,7 @@
12
12
  "bugs": {
13
13
  "url": "https://github.com/underactive/pi-topping/issues"
14
14
  },
15
- "homepage": "https://github.com/underactive/pi-topping#readme",
15
+ "homepage": "https://esison.dev/pi-topping",
16
16
  "type": "module",
17
17
  "engines": {
18
18
  "node": ">=22.19.0"
package/src/format.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
- import type { ThinkingLevelColor, ThinkingLevelSettingColor } from "./settings.ts";
2
+ import type { ThinkingLevelColor } from "./settings.ts";
3
3
 
4
4
  /** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
5
5
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -21,7 +21,7 @@ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
21
21
  /** Build a colorizer for a configured color, including Pi's active thinking-level color. */
22
22
  export function getThinkingLevelColorizer(
23
23
  theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
24
- color: ThinkingLevelColor | ThinkingLevelSettingColor,
24
+ color: ThinkingLevelColor,
25
25
  thinkingLevel: ThinkingLevel | undefined,
26
26
  ): (text: string) => string {
27
27
  return color === "thinking-level"
@@ -105,6 +105,25 @@ export const RESPONSE_MODEL_HOLD_MS = 3_000;
105
105
  /** Milliseconds over which a settled response model fades through the theme shades. */
106
106
  export const RESPONSE_MODEL_FADE_MS = 500;
107
107
 
108
+ /** Milliseconds over which a newly shown response model slides out of the working loader. */
109
+ export const RESPONSE_MODEL_SLIDE_MS = 300;
110
+
111
+ /** Milliseconds between working-loader repaints while the response model slides out. */
112
+ export const RESPONSE_MODEL_SLIDE_FRAME_MS = 30;
113
+
114
+ /**
115
+ * The part of a sliding response model visible `elapsedMs` after it appeared. It emerges
116
+ * tail first from behind the separator before it, as pi-topping-statusline slides an
117
+ * embedded status out, and shows whole once RESPONSE_MODEL_SLIDE_MS has passed.
118
+ */
119
+ export function slideOutTail(text: string, elapsedMs: number): string {
120
+ if (elapsedMs >= RESPONSE_MODEL_SLIDE_MS) return text;
121
+ const chars = [...text];
122
+ const progress = Math.max(0, elapsedMs) / RESPONSE_MODEL_SLIDE_MS;
123
+ const shown = Math.round(chars.length * 0.5 * (1 - Math.cos(Math.PI * progress)));
124
+ return chars.slice(chars.length - shown).join("");
125
+ }
126
+
108
127
  /** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
109
128
  export function fadeThemeColorString(
110
129
  text: string,
@@ -0,0 +1,58 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, fadeThemeColorString, formatElapsed, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES, TOKEN_RATE_PLACEHOLDER, type LoaderElement, type ThinkingLevel } from "./format.ts";
3
+ import { getResponseModelColorizer } from "./nvidia-green.ts";
4
+ import type { DecoratorSettings } from "./settings.ts";
5
+
6
+ type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate" | "responseModel">;
7
+ type Decorations = Pick<DecoratorSettings["decorations"], "shimmer" | "shimmerInverted" | "shimmerDirection" | "shimmerSpeed" | "tokenActivityMonitor" | "meterColor" | "meterDimmed" | "tokenRateColor" | "tokenRateDimmed" | "responseModelColor" | "responseModelDimmed" | "spinnerColor">;
8
+ interface LoaderParts {
9
+ spinnerAtMs?: number;
10
+ word: string;
11
+ shimmerAtMs?: number;
12
+ renderMeter?: (colorizer: (text: string) => string, dimmed: boolean) => string;
13
+ elapsedMs?: number;
14
+ outputTokens?: number;
15
+ tokenRateText?: string;
16
+ tokenRateShade?: number;
17
+ responseModel?: string;
18
+ }
19
+
20
+ /** Assemble the live and preview loaders with identical styling and ordering. */
21
+ export function buildLoaderMessage(
22
+ theme: Theme,
23
+ thinkingLevel: ThinkingLevel | undefined,
24
+ provider: string | undefined,
25
+ features: Features,
26
+ decorations: Decorations,
27
+ loaderOrder: readonly LoaderElement[],
28
+ parts: LoaderParts,
29
+ ): string {
30
+ const spinner = parts.spinnerAtMs === undefined ? "" : getThinkingLevelColorizer(theme, decorations.spinnerColor, thinkingLevel)(SPINNER_FRAMES[Math.floor(parts.spinnerAtMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
31
+ const responseModelColored = parts.responseModel ? getResponseModelColorizer(theme, decorations.responseModelColor, thinkingLevel, provider)(parts.responseModel) : "";
32
+ const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
33
+ if (isFullyDefaultAppearance(features, decorations)) {
34
+ return buildWorkingMessage(theme, { spinner, text: theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, loaderOrder);
35
+ }
36
+ const word = features.substituteDefaultMessage ? parts.word : DEFAULT_WORKING_WORD;
37
+ const text = decorations.shimmer && parts.shimmerAtMs !== undefined
38
+ ? shimmerString(word, parts.shimmerAtMs, theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
39
+ : theme.fg("text", word);
40
+ const meter = decorations.tokenActivityMonitor && parts.renderMeter
41
+ ? parts.renderMeter(getThinkingLevelColorizer(theme, decorations.meterColor, thinkingLevel), decorations.meterDimmed)
42
+ : "";
43
+ let tokenRate = "";
44
+ if (features.tokenRate) {
45
+ if (!parts.tokenRateText) tokenRate = theme.fg("dim", TOKEN_RATE_PLACEHOLDER);
46
+ else {
47
+ const colorizer = getThinkingLevelColorizer(theme, decorations.tokenRateColor, thinkingLevel);
48
+ tokenRate = parts.tokenRateShade === undefined ? colorizer(parts.tokenRateText) : fadeThemeColorString(parts.tokenRateText, parts.tokenRateShade, theme, colorizer);
49
+ }
50
+ if (tokenRate && decorations.tokenRateDimmed) tokenRate = dimAttribute(tokenRate);
51
+ }
52
+ return buildWorkingMessage(theme, {
53
+ spinner, text, meter,
54
+ elapsed: features.elapsedTime && parts.elapsedMs !== undefined ? formatElapsed(parts.elapsedMs) : "",
55
+ tokens: features.outputTokens && parts.outputTokens !== undefined ? `↓ ${formatTokens(parts.outputTokens)} tokens` : "",
56
+ tokenRate, responseModel,
57
+ }, loaderOrder);
58
+ }
package/src/menu.ts CHANGED
@@ -26,6 +26,10 @@ export interface MenuItem {
26
26
  id: string;
27
27
  label: string;
28
28
  value: MenuValue;
29
+ /** Render a blank, non-selectable row immediately before this item. */
30
+ spacerBefore?: boolean;
31
+ /** IDs for which at least one value must be true before this item can be toggled. */
32
+ disabledUnlessAnyOf?: readonly string[];
29
33
  /** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
30
34
  cycleValues?: readonly string[];
31
35
  /** Optional display labels for cycle values; the keys remain the published values. */
@@ -215,6 +219,7 @@ export class MenuComponent implements Component {
215
219
  [Key.right]: () => this.cycleCurrentValue(1),
216
220
  [Key.space]: () => {
217
221
  const item = this.flat[this.cursor]!.item;
222
+ if (this.isDisabled(item)) return;
218
223
  if (item.cycleValues && item.cycleEnabledBy) {
219
224
  this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
220
225
  if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
@@ -286,9 +291,14 @@ export class MenuComponent implements Component {
286
291
  return Math.max(1, rows - 6);
287
292
  }
288
293
 
294
+ private isDisabled(item: MenuItem): boolean {
295
+ const requiredIds = item.disabledUnlessAnyOf;
296
+ return requiredIds !== undefined && !requiredIds.some(id => this.values[id] === true);
297
+ }
298
+
289
299
  private cycleCurrentValue(delta: number): void {
290
300
  const item = this.flat[this.cursor]!.item;
291
- if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
301
+ if (this.isDisabled(item) || !item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
292
302
  const current = item.cycleValues.indexOf(this.values[item.id] as string);
293
303
  const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
294
304
  this.values[item.id] = item.cycleValues[index]!;
@@ -338,6 +348,7 @@ export class MenuComponent implements Component {
338
348
  lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
339
349
  currentSection = flat.sectionIndex;
340
350
  }
351
+ if (flat.item.spacerBefore) lines.push(this.renderBlankRow(innerWidth));
341
352
  lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
342
353
  }
343
354
  if (lines.length) lines.push(this.renderBlankRow(innerWidth));
@@ -363,6 +374,7 @@ export class MenuComponent implements Component {
363
374
  if (maxRows - lines.length >= 2) lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
364
375
  currentSection = flat.sectionIndex;
365
376
  }
377
+ if (flat.item.spacerBefore && maxRows - lines.length >= 2) lines.push(this.renderBlankRow(innerWidth));
366
378
  if (lines.length >= maxRows) break;
367
379
  lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
368
380
  end = index;
@@ -388,18 +400,20 @@ export class MenuComponent implements Component {
388
400
 
389
401
  // Walk backward from cursor to find the lowest scrollStart that fits in contentRows.
390
402
  {
391
- let rowsUsed = 2; // 1 for cursor item + 1 for first-section divider
403
+ let rowsUsed = 2 + (this.flat[this.cursor]!.item.spacerBefore ? 1 : 0); // cursor item + first-section divider + optional spacer
392
404
  let section = this.flat[this.cursor]!.sectionIndex;
393
405
  this.scrollStart = this.cursor;
394
406
  for (let i = this.cursor - 1; i >= 0; i--) {
395
- const sec = this.flat[i]!.sectionIndex;
407
+ const previous = this.flat[i]!;
408
+ const sec = previous.sectionIndex;
409
+ const itemRows = 1 + (previous.item.spacerBefore ? 1 : 0);
396
410
  if (sec !== section) {
397
- if (rowsUsed + 3 > contentRows) break;
398
- rowsUsed += 3;
411
+ if (rowsUsed + itemRows + 2 > contentRows) break;
412
+ rowsUsed += itemRows + 2;
399
413
  section = sec;
400
414
  } else {
401
- if (rowsUsed + 1 > contentRows) break;
402
- rowsUsed += 1;
415
+ if (rowsUsed + itemRows > contentRows) break;
416
+ rowsUsed += itemRows;
403
417
  }
404
418
  this.scrollStart = i;
405
419
  }
@@ -438,7 +452,8 @@ export class MenuComponent implements Component {
438
452
  const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
439
453
  const footer = this.buildFooter(innerWidth);
440
454
  const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size;
441
- const naturalBodyLength = this.flat.length + 2 * sectionsWithItems;
455
+ const spacerRows = this.flat.filter(flat => flat.item.spacerBefore).length;
456
+ const naturalBodyLength = this.flat.length + spacerRows + 2 * sectionsWithItems;
442
457
  if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) {
443
458
  const naturalBody = this.buildToggleSections(innerWidth);
444
459
  this.pageItemCount = Math.max(1, this.flat.length);
@@ -542,7 +557,7 @@ export class MenuComponent implements Component {
542
557
  }
543
558
 
544
559
  if (item.cycleValues) {
545
- const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
560
+ const enabled = !this.isDisabled(item) && (item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true);
546
561
  const rawValue = typeof value === "string" ? value : String(value);
547
562
  const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue;
548
563
  const stateWord = `‹ ${displayValue} ›`;
@@ -554,7 +569,8 @@ export class MenuComponent implements Component {
554
569
  return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
555
570
  }
556
571
 
557
- const enabled = value as boolean;
572
+ const disabled = this.isDisabled(item);
573
+ const enabled = disabled || value as boolean;
558
574
  const box = enabled ? "\u25a0" : " ";
559
575
  const stateWord = enabled ? "ON" : "OFF";
560
576
  const rightPlain = `${stateWord} `;
@@ -562,7 +578,9 @@ export class MenuComponent implements Component {
562
578
  const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
563
579
  const leftPlain = ` ${marker} [${box}] ${label}`;
564
580
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
565
- const content = ` ${markerColored} [${enabled ? th.fg("success", box) : th.fg("muted", box)}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("success", stateWord) : th.fg("muted", stateWord)} `;
581
+ const labelColor = disabled ? "muted" : "text";
582
+ const stateColor = disabled ? "muted" : enabled ? "success" : "muted";
583
+ const content = ` ${markerColored} [${th.fg(stateColor, box)}] ${th.fg(labelColor, label)}${" ".repeat(gap)}${th.fg(stateColor, stateWord)} `;
566
584
  return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
567
585
  }
568
586
  }
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { getThinkingLevelColorizer, type ThinkingLevel } from "./format.ts";
3
- import type { ThinkingLevelColor, ThinkingLevelSettingColor } from "./settings.ts";
3
+ import type { ThinkingLevelColor } from "./settings.ts";
4
4
 
5
5
  /** Provider id whose working-loader response model uses NVIDIA green. */
6
6
  export const SWITCHYARD_PROVIDER = "switchyard";
@@ -27,7 +27,7 @@ export function colorizeNvidiaGreen(text: string): string {
27
27
  */
28
28
  export function getResponseModelColorizer(
29
29
  theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
30
- color: ThinkingLevelColor | ThinkingLevelSettingColor,
30
+ color: ThinkingLevelColor,
31
31
  thinkingLevel: ThinkingLevel | undefined,
32
32
  provider: string | undefined,
33
33
  ): (text: string) => string {
package/src/preview.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
3
3
  import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
4
- import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
4
+ import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRate, getThinkingLevelColorizer, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
5
+ import { buildLoaderMessage } from "./loader-message.ts";
5
6
  import { getResponseModelColorizer } from "./nvidia-green.ts";
6
7
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
7
8
  import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
8
- import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
9
+ import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, wordPacksPath, type WordPack } from "./word-packs.ts";
9
10
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
10
11
  // flat 28 tps for the token readouts.
11
12
  const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
@@ -31,7 +32,13 @@ export class PreviewRenderer {
31
32
  if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
32
33
  if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
33
34
  if (activeItemId === "useNerdFont") return { lines: ["", `Icon preview: ${values.useNerdFont ? "" : "π"}`, ""] };
34
- if (activeItemId?.startsWith("pack:")) return this.packPreview(activeItemId.slice("pack:".length), values);
35
+ if (activeItemId === "showResponseModelFooter") {
36
+ if (values.showResponseModelFooter !== true) return { lines: ["", "Response model footer is disabled.", ""] };
37
+ const color = isThinkingLevelColor(values.responseModelColor) ? values.responseModelColor : DEFAULT_SETTINGS.decorations.responseModelColor;
38
+ const colored = getResponseModelColorizer(this.#ctx.ui.theme, color, this.#ctx.thinkingLevel, this.#ctx.model?.provider)("test-model");
39
+ return { lines: ["", values.responseModelDimmed === true ? dimAttribute(colored) : colored, ""] };
40
+ }
41
+ if (activeItemId?.startsWith(WORD_PACK_MENU_PREFIX)) return this.packPreview(activeItemId.slice(WORD_PACK_MENU_PREFIX.length), values);
35
42
  let nextRefreshInMs: number | undefined;
36
43
  if (values.shimmer !== false) {
37
44
  nextRefreshInMs = SHIMMER_INTERVAL_MS;
@@ -51,88 +58,47 @@ export class PreviewRenderer {
51
58
  this.#lastMeterUpdate = elapsedMs;
52
59
  }
53
60
  const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
54
- const decorations = { shimmer: values.shimmer !== false, shimmerInverted: values.shimmerInverted === true, tokenActivityMonitor: values.tokenActivityMonitor !== false };
55
- let spinnerColor = DEFAULT_SETTINGS.decorations.spinnerColor;
56
- if (isSpinnerColor(values.spinnerColor)) {
57
- spinnerColor = values.spinnerColor;
58
- }
59
- let spinner = "";
60
- if (values.animatedSpinner !== false) {
61
- spinner = getThinkingLevelColorizer(this.#ctx.ui.theme, spinnerColor, this.#ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
62
- }
63
- const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
64
- let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
65
- if (isThinkingLevelColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
66
- const responseModelColorizer = getResponseModelColorizer(this.#ctx.ui.theme, responseModelColor, this.#ctx.thinkingLevel, this.#ctx.model?.provider);
67
- const responseModelColored = features.responseModel ? responseModelColorizer("test-model") : "";
68
- const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
69
- if (isFullyDefaultAppearance(features, decorations)) {
70
- return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, order);
71
- }
61
+ const decorations = {
62
+ shimmer: values.shimmer !== false,
63
+ shimmerInverted: values.shimmerInverted === true,
64
+ shimmerDirection: fromCycleDirection(values.shimmerDirection),
65
+ shimmerSpeed: fromCycleSpeed(values.shimmerSpeed),
66
+ tokenActivityMonitor: values.tokenActivityMonitor !== false,
67
+ meterColor: isThinkingLevelColor(values.meterColor) ? values.meterColor : DEFAULT_SETTINGS.decorations.meterColor,
68
+ meterDimmed: values.meterDimmed === true,
69
+ tokenRateColor: isThinkingLevelColor(values.tokenRateColor) ? values.tokenRateColor : DEFAULT_SETTINGS.decorations.tokenRateColor,
70
+ tokenRateDimmed: values.tokenRateDimmed === true,
71
+ responseModelColor: isThinkingLevelColor(values.responseModelColor) ? values.responseModelColor : DEFAULT_SETTINGS.decorations.responseModelColor,
72
+ responseModelDimmed: values.responseModelDimmed === true,
73
+ spinnerColor: isSpinnerColor(values.spinnerColor) ? values.spinnerColor : DEFAULT_SETTINGS.decorations.spinnerColor,
74
+ };
72
75
  let word = DEFAULT_WORKING_WORD;
73
76
  if (features.substituteDefaultMessage) {
74
77
  const packValues = this.packValues(values);
75
- const signature = JSON.stringify(Object.keys(packValues).sort().map(k => `${k}=${packValues[k]}`));
78
+ const includeDefaultWorkingText = values.includeDefaultWorkingText !== false;
79
+ const signature = JSON.stringify([packValues, includeDefaultWorkingText]);
76
80
  if (signature !== this.#cachedPackSignature) {
77
- this.#cachedPackWord = selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text;
81
+ this.#cachedPackWord = selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction, includeDefaultWorkingText).text;
78
82
  this.#cachedPackSignature = signature;
79
83
  }
80
84
  word = this.#cachedPackWord;
81
85
  }
82
- let styledWord: string;
83
- if (decorations.shimmer) {
84
- styledWord = shimmerString(word, elapsedMs, this.#ctx.ui.theme, fromCycleDirection(values.shimmerDirection), fromCycleSpeed(values.shimmerSpeed), decorations.shimmerInverted);
85
- } else {
86
- styledWord = this.#ctx.ui.theme.fg("text", word);
87
- }
88
- let meterColor = DEFAULT_SETTINGS.decorations.meterColor;
89
- if (isThinkingLevelColor(values.meterColor)) {
90
- meterColor = values.meterColor;
91
- }
92
- let meter = "";
93
- if (decorations.tokenActivityMonitor) {
94
- const meterColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, meterColor, this.#ctx.thinkingLevel);
95
- meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColorizer, values.meterDimmed === true));
96
- }
97
- let tokenRateText = "";
98
- if (features.tokenRate) {
99
- tokenRateText = formatTokenRate(TOKEN_RATE_PER_SEC);
100
- }
101
- let tokenRateColor = DEFAULT_SETTINGS.decorations.tokenRateColor;
102
- if (isThinkingLevelColor(values.tokenRateColor)) {
103
- tokenRateColor = values.tokenRateColor;
104
- }
105
- let tokenRateColored = "";
106
- if (tokenRateText) {
107
- const tokenRateColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, tokenRateColor, this.#ctx.thinkingLevel);
108
- tokenRateColored = tokenRateColorizer(tokenRateText);
109
- }
110
- let tokenRate = tokenRateColored;
111
- if (tokenRateColored && values.tokenRateDimmed === true) {
112
- tokenRate = dimAttribute(tokenRateColored);
113
- }
114
- let elapsed = "";
115
- if (features.elapsedTime) {
116
- elapsed = formatElapsed(elapsedMs);
117
- }
118
- let tokens = "";
119
- if (features.outputTokens) {
120
- tokens = `↓ ${formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)))} tokens`;
121
- }
122
- return buildWorkingMessage(this.#ctx.ui.theme, {
123
- spinner,
124
- text: styledWord,
125
- meter,
126
- elapsed,
127
- tokens,
128
- tokenRate,
129
- responseModel,
130
- }, order);
86
+ const theme = this.#ctx.ui.theme;
87
+ return buildLoaderMessage(theme, this.#ctx.thinkingLevel, this.#ctx.model?.provider, features, decorations, parseLoaderOrder(values[LOADER_ORDER_ID]), {
88
+ spinnerAtMs: values.animatedSpinner !== false ? elapsedMs : undefined,
89
+ word,
90
+ shimmerAtMs: elapsedMs,
91
+ renderMeter: (colorizer, dimmed) => this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, theme, colorizer, dimmed)),
92
+ elapsedMs,
93
+ outputTokens: Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)),
94
+ tokenRateText: formatTokenRate(TOKEN_RATE_PER_SEC),
95
+ responseModel: features.responseModel ? "test-model" : undefined,
96
+ });
131
97
  }
132
98
  private packValues(values: Record<string, boolean | string>): Record<string, boolean> {
133
99
  const enabled: Record<string, boolean> = {};
134
100
  for (const pack of this.#packs) {
135
- const value = values[`pack:${pack.id}`];
101
+ const value = values[`${WORD_PACK_MENU_PREFIX}${pack.id}`];
136
102
  if (typeof value === "boolean") enabled[pack.id] = value;
137
103
  }
138
104
  return enabled;
package/src/session.ts CHANGED
@@ -15,8 +15,6 @@ import type {
15
15
  import { Text, type Component } from "@earendil-works/pi-tui";
16
16
  import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
17
17
  import {
18
- buildWorkingMessage,
19
- DEFAULT_WORKING_WORD,
20
18
  dimAttribute,
21
19
  getThinkingLevelColorizer,
22
20
  isThinkingLevel,
@@ -29,19 +27,22 @@ import {
29
27
  METER_INTERVAL_MS,
30
28
  RESPONSE_MODEL_FADE_MS,
31
29
  RESPONSE_MODEL_HOLD_MS,
30
+ RESPONSE_MODEL_SLIDE_FRAME_MS,
31
+ RESPONSE_MODEL_SLIDE_MS,
32
32
  SHIMMER_INTERVAL_MS,
33
- shimmerString,
33
+ slideOutTail,
34
34
  SPINNER_FRAME_MS,
35
35
  SPINNER_FRAMES,
36
36
  TOKEN_RATE_FADE_SHADE_COUNT,
37
- TOKEN_RATE_PLACEHOLDER,
38
37
  StreamingWordCounter,
39
38
  type ThinkingLevel,
40
39
  } from "./format.ts";
40
+ import { buildLoaderMessage } from "./loader-message.ts";
41
41
  import { showMenu } from "./menu.ts";
42
42
  import { getResponseModelColorizer } from "./nvidia-green.ts";
43
43
  import { registerSetupCommand } from "./setup-command.ts";
44
44
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
45
+ import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.ts";
45
46
  import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
46
47
  import { PreviewRenderer } from "./preview.ts";
47
48
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
@@ -96,6 +97,9 @@ interface SessionState {
96
97
  responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
97
98
  responseModelFadeTimer: ReturnType<typeof setInterval> | null;
98
99
  responseModelFadeGeneration: number;
100
+ /** When the response model appeared in the loader and began sliding out; 0 if it never slid. */
101
+ responseModelShownAt: number;
102
+ responseModelSlideTimer: ReturnType<typeof setInterval> | null;
99
103
  tokenRateText: string;
100
104
  tokenRateFadeStartsAt: number;
101
105
  timer: ReturnType<typeof setInterval> | null;
@@ -134,6 +138,8 @@ function makeFreshState(): SessionState {
134
138
  responseModelHoldTimer: null,
135
139
  responseModelFadeTimer: null,
136
140
  responseModelFadeGeneration: 0,
141
+ responseModelShownAt: 0,
142
+ responseModelSlideTimer: null,
137
143
  tokenRateText: "",
138
144
  tokenRateFadeStartsAt: 0,
139
145
  timer: null,
@@ -162,6 +168,8 @@ export class SessionManager {
162
168
  #bundledPacks = loadBundledWordPacks();
163
169
  #allPacks: WordPack[] = [...this.#bundledPacks];
164
170
  #currentCtx: ExtensionContext | null = null;
171
+ /** Whether pi-topping-statusline last announced that its status bar hosts the loader; kept across sessions. */
172
+ #loaderInStatusline = false;
165
173
  readonly #pi: ExtensionAPI;
166
174
 
167
175
  constructor(pi: ExtensionAPI) {
@@ -171,6 +179,7 @@ export class SessionManager {
171
179
  #onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
172
180
  this.#currentCtx = ctx;
173
181
  this.stopTimer();
182
+ this.stopResponseModelSlide();
174
183
  this.cancelResponseModelFade(ctx);
175
184
  this.#counter.reset();
176
185
  this.#state = makeFreshState();
@@ -308,7 +317,7 @@ export class SessionManager {
308
317
 
309
318
  const hadPrompt = !!this.#state.startTime;
310
319
  const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
311
- const responseModel = this.#settings.features.responseModel ? this.#state.responseModel : "";
320
+ const responseModel = this.#settings.features.responseModelFooter ? this.#state.responseModel : "";
312
321
  const responseModelColor = this.#settings.decorations.responseModelColor;
313
322
  const responseModelDimmed = this.#settings.decorations.responseModelDimmed;
314
323
  this.#state.busy = false;
@@ -316,6 +325,7 @@ export class SessionManager {
316
325
  this.#state.startTime = 0;
317
326
  this.#counter.reset();
318
327
  this.stopTimer();
328
+ this.stopResponseModelSlide();
319
329
  this.applyIndicator(ctx);
320
330
  ctx.ui.setWorkingMessage();
321
331
  if (responseModel) this.startResponseModelFade(ctx, responseModel, responseModelColor, responseModelDimmed);
@@ -339,6 +349,7 @@ export class SessionManager {
339
349
  this.#state.waiting = null;
340
350
  this.cancelResponseModelFade(ctx);
341
351
  this.stopTimer();
352
+ this.stopResponseModelSlide();
342
353
  };
343
354
 
344
355
  #renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
@@ -393,6 +404,9 @@ export class SessionManager {
393
404
  this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
394
405
  this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
395
406
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
407
+ this.#pi.events.on(STATUSLINE_EMBED_CHANNEL, (data) => {
408
+ this.#loaderInStatusline = announcesEmbedded(data);
409
+ });
396
410
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
397
411
  this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
398
412
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
@@ -469,16 +483,47 @@ export class SessionManager {
469
483
  if (responseModel && modelsResemble(selectedModel, responseModel)) {
470
484
  if (this.#state.responseModel) {
471
485
  this.#state.responseModel = "";
486
+ this.stopResponseModelSlide();
472
487
  this.tick();
473
488
  }
474
489
  return;
475
490
  }
476
491
  if (responseModel && responseModel !== this.#state.responseModel) {
492
+ // A model replacing one already shown swaps in place; only an appearing one slides out.
493
+ if (!this.#state.responseModel) this.startResponseModelSlide();
477
494
  this.#state.responseModel = responseModel;
478
495
  this.tick();
479
496
  }
480
497
  }
481
498
 
499
+ /**
500
+ * Slide a newly shown response model out of the loader while pi-topping-statusline
501
+ * embeds it, matching that status bar's own slides; anywhere else it shows at once.
502
+ * The loader's own timer runs at 50ms or slower, so the slide repaints on its own
503
+ * faster timer until it is done.
504
+ */
505
+ private startResponseModelSlide(): void {
506
+ this.stopResponseModelSlide();
507
+ const state = this.#state;
508
+ if (!this.#settings.features.responseModel || !this.#loaderInStatusline) {
509
+ state.responseModelShownAt = 0;
510
+ return;
511
+ }
512
+ state.responseModelShownAt = Date.now();
513
+ state.responseModelSlideTimer = setInterval(() => {
514
+ this.tick();
515
+ if (Date.now() - state.responseModelShownAt >= RESPONSE_MODEL_SLIDE_MS) this.stopResponseModelSlide();
516
+ }, RESPONSE_MODEL_SLIDE_FRAME_MS);
517
+ }
518
+
519
+ private stopResponseModelSlide(): void {
520
+ const state = this.#state;
521
+ if (state.responseModelSlideTimer) {
522
+ clearInterval(state.responseModelSlideTimer);
523
+ state.responseModelSlideTimer = null;
524
+ }
525
+ }
526
+
482
527
  private cancelResponseModelFade(ctx?: ExtensionContext | null): void {
483
528
  const state = this.#state;
484
529
  state.responseModelFadeGeneration++;
@@ -543,11 +588,12 @@ export class SessionManager {
543
588
  }
544
589
 
545
590
  private pickWorkingWord(): WorkingTextSelection {
546
- return pickWorkingTextSelection(this.#settings.wordPacks, this.#allPacks);
591
+ return pickWorkingTextSelection(this.#settings.wordPacks, this.#allPacks, this.#settings.features.includeDefaultWorkingText);
547
592
  }
548
593
 
549
594
  private resetTurn(now: number): void {
550
595
  this.cancelResponseModelFade();
596
+ this.stopResponseModelSlide();
551
597
  const state = this.#state;
552
598
  state.startTime = now;
553
599
  state.shimmerOrigin = now;
@@ -589,36 +635,9 @@ export class SessionManager {
589
635
  if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
590
636
  state.lastTokenRateSampledAt = now;
591
637
  }
592
- const spinner = this.spinnerInMessage()
593
- ? getThinkingLevelColorizer(ctx.ui.theme, decorations.spinnerColor, ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
594
- : "";
595
- const responseModelColored = features.responseModel && state.responseModel
596
- ? getResponseModelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel, ctx.model?.provider)(state.responseModel)
638
+ const responseModelShown = features.responseModel && state.responseModel
639
+ ? slideOutTail(state.responseModel, now - state.responseModelShownAt)
597
640
  : "";
598
- const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
599
- if (isFullyDefaultAppearance(features, decorations)) {
600
- const msg = spinner || responseModel
601
- ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, this.#settings.loaderOrder)
602
- : undefined;
603
- if (msg !== state.lastMessage) {
604
- state.lastMessage = msg;
605
- ctx.ui.setWorkingMessage(msg);
606
- }
607
- return;
608
- }
609
-
610
- const word = features.substituteDefaultMessage ? state.workingText.text : DEFAULT_WORKING_WORD;
611
- const styled = decorations.shimmer
612
- ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
613
- : ctx.ui.theme.fg("text", word);
614
- let meter = "";
615
- if (decorations.tokenActivityMonitor) {
616
- const meterColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.meterColor, ctx.thinkingLevel);
617
- meter = state.activityMeter.render((level, char) =>
618
- ActivityMeter.colorizeCell(level, char, ctx.ui.theme, meterColorizer, decorations.meterDimmed),
619
- );
620
- }
621
- let tokenRateText = "";
622
641
  if (features.tokenRate) {
623
642
  const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
624
643
  if (latestTokenRate && hasNewTokenCount) {
@@ -627,38 +646,33 @@ export class SessionManager {
627
646
  } else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
628
647
  state.tokenRateText = "";
629
648
  }
630
- tokenRateText = state.tokenRateText;
631
- }
632
- let tokenRateSegment = "";
633
- if (features.tokenRate) {
634
- if (!tokenRateText) {
635
- tokenRateSegment = ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER);
636
- } else {
637
- const tokenRateColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.tokenRateColor, ctx.thinkingLevel);
638
- tokenRateSegment = now < state.tokenRateFadeStartsAt
639
- ? tokenRateColorizer(tokenRateText)
640
- : fadeThemeColorString(
641
- tokenRateText,
642
- Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
643
- ctx.ui.theme,
644
- tokenRateColorizer,
645
- );
646
- }
647
649
  }
648
- const tokenRateStyled = tokenRateSegment && decorations.tokenRateDimmed ? dimAttribute(tokenRateSegment) : tokenRateSegment;
649
- const msg = buildWorkingMessage(
650
- ctx.ui.theme,
651
- {
652
- spinner,
653
- text: styled,
654
- meter,
655
- elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
656
- tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
657
- tokenRate: tokenRateStyled,
658
- responseModel,
659
- },
660
- this.#settings.loaderOrder,
661
- );
650
+ const showSpinner = this.spinnerInMessage();
651
+ const msg = isFullyDefaultAppearance(features, decorations) && !showSpinner && !responseModelShown
652
+ ? undefined
653
+ : buildLoaderMessage(
654
+ ctx.ui.theme,
655
+ ctx.thinkingLevel,
656
+ ctx.model?.provider,
657
+ features,
658
+ decorations,
659
+ this.#settings.loaderOrder,
660
+ {
661
+ spinnerAtMs: showSpinner ? now : undefined,
662
+ word: state.workingText.text,
663
+ shimmerAtMs: now - state.shimmerOrigin,
664
+ renderMeter: (colorizer, dimmed) => state.activityMeter.render((level, char) =>
665
+ ActivityMeter.colorizeCell(level, char, ctx.ui.theme, colorizer, dimmed),
666
+ ),
667
+ elapsedMs: now - state.startTime,
668
+ outputTokens: total,
669
+ tokenRateText: state.tokenRateText,
670
+ tokenRateShade: now < state.tokenRateFadeStartsAt
671
+ ? undefined
672
+ : Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
673
+ responseModel: responseModelShown,
674
+ },
675
+ );
662
676
  if (msg !== state.lastMessage) {
663
677
  state.lastMessage = msg;
664
678
  ctx.ui.setWorkingMessage(msg);
package/src/settings.ts CHANGED
@@ -3,7 +3,7 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "
3
3
  import { dirname, join } from "node:path";
4
4
  import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
5
5
  import type { MenuSection } from "./menu.ts";
6
- import { isWordPackEnabled, isWordPackId, type WordPack } from "./word-packs.ts";
6
+ import { isWordPackEnabled, isWordPackId, WORD_PACK_MENU_PREFIX, type WordPack } from "./word-packs.ts";
7
7
  import { isPlainObject } from "./util.ts";
8
8
 
9
9
  export const SETTING_COLOR_VALUES = ["accent", "border", "borderAccent", "success", "error", "warning"] as const;
@@ -20,7 +20,8 @@ export const DONE_MARKER_BORDER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALU
20
20
  export type DoneMarkerBorderColor = ThinkingLevelSettingColor;
21
21
  const THINKING_LEVEL_COLOR_MIGRATION_VERSION = 2;
22
22
  const DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION = 3;
23
- export const SETTINGS_SCHEMA_VERSION = 3;
23
+ const RESPONSE_MODEL_FOOTER_MIGRATION_VERSION = 4;
24
+ export const SETTINGS_SCHEMA_VERSION = 4;
24
25
 
25
26
  export const BORDER_STYLE_VALUES = ["double", "single", "rounded", "heavy"] as const;
26
27
  export type BorderStyle = (typeof BORDER_STYLE_VALUES)[number];
@@ -96,12 +97,14 @@ export interface DecoratorSettings {
96
97
  outputTokens: boolean;
97
98
  tokenRate: boolean;
98
99
  responseModel: boolean;
100
+ responseModelFooter: boolean;
99
101
  doneMarker: boolean;
100
102
  doneMarkerIcon: boolean;
101
103
  randomizeDoneMarker: boolean;
102
104
  doneMarkerTokens: boolean;
103
105
  doneMarkerInputs: boolean;
104
106
  doneMarkerModel: boolean;
107
+ includeDefaultWorkingText: boolean;
105
108
  };
106
109
  loaderOrder: LoaderElement[];
107
110
  wordPacks: Record<string, boolean>;
@@ -109,7 +112,7 @@ export interface DecoratorSettings {
109
112
 
110
113
  export const DEFAULT_SETTINGS: DecoratorSettings = {
111
114
  decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "thinking-level", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "thinking-level", doneMarkerStyle: "elite", doneMarkerModelColor: "muted", doneMarkerModelDimmed: false, spinnerColor: "thinking-level", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
112
- features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true },
115
+ features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true, includeDefaultWorkingText: true },
113
116
  loaderOrder: [...DEFAULT_LOADER_ORDER],
114
117
  wordPacks: {},
115
118
  };
@@ -148,20 +151,7 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
148
151
  if (!isPlainObject(parsed)) return merged;
149
152
  for (const [key, value] of Object.entries(parsed)) {
150
153
  if (!Object.hasOwn(merged, key)) continue;
151
- let valid: boolean | string | undefined;
152
- if (typeof merged[key] === "boolean" && typeof value === "boolean") valid = value;
153
- else if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) valid = value;
154
- else if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) valid = value;
155
- else if (key === "spinnerColor" && value === "default") valid = "thinking-level";
156
- else if (key === "spinnerColor" && isSpinnerColor(value)) valid = value;
157
- else if (key === "borderColor" && isPromptBorderColor(value)) valid = value;
158
- else if (key === "doneMarkerBorderColor" && value === "default") valid = "thinking-level";
159
- else if (key === "doneMarkerBorderColor" && isDoneMarkerBorderColor(value)) valid = value;
160
- else if ((key === "meterColor" || key === "tokenRateColor" || key === "responseModelColor" || key === "doneMarkerModelColor") && isThinkingLevelColor(value)) valid = value;
161
- else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
162
- else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
163
- else if (key === "doneMarkerStyle" && isDoneMarkerStyle(value)) valid = value;
164
- if (valid !== undefined) (merged as Record<string, boolean | string>)[key] = valid;
154
+ if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
165
155
  }
166
156
  return merged;
167
157
  }
@@ -176,7 +166,7 @@ export function fromCycleSpeed(value: unknown): "slow" | "normal" | "fast" {
176
166
  return value === "slow" || value === "fast" ? value : "normal";
177
167
  }
178
168
 
179
- type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
169
+ type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Word Packs" | "Options";
180
170
  type DecorationSettings = DecoratorSettings["decorations"];
181
171
  type FeatureSettings = DecoratorSettings["features"];
182
172
  type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
@@ -228,6 +218,8 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
228
218
  { id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
229
219
  { id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" },
230
220
  { id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
221
+ { id: "showResponseModelFooter", label: "Response model footer", section: "Options", group: "features", key: "responseModelFooter" },
222
+ { id: "includeDefaultWorkingText", label: "Include default/random “Working” text", section: "Word Packs", group: "features", key: "includeDefaultWorkingText" },
231
223
  ];
232
224
 
233
225
  function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["items"][number] {
@@ -245,10 +237,9 @@ function isDecorationBooleanKey(key: keyof DecorationSettings): key is Decoratio
245
237
  function setDecorationCycleValue(decorations: DecorationSettings, key: keyof DecorationSettings, value: string): void {
246
238
  switch (key) {
247
239
  case "borderColor":
248
- if (isPromptBorderColor(value)) decorations[key] = value;
249
- return;
250
240
  case "spinnerColor":
251
- if (isSpinnerColor(value)) decorations[key] = value;
241
+ case "doneMarkerBorderColor":
242
+ if (isThinkingLevelSettingColor(value)) decorations[key] = value;
252
243
  return;
253
244
  case "meterColor":
254
245
  case "tokenRateColor":
@@ -256,9 +247,6 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
256
247
  case "doneMarkerModelColor":
257
248
  if (isThinkingLevelColor(value)) decorations[key] = value;
258
249
  return;
259
- case "doneMarkerBorderColor":
260
- if (isDoneMarkerBorderColor(value)) decorations[key] = value;
261
- return;
262
250
  case "borderStyle":
263
251
  if (isBorderStyle(value)) decorations[key] = value;
264
252
  return;
@@ -287,12 +275,20 @@ function buildSection(title: MenuSectionName, settings: DecoratorSettings): Menu
287
275
 
288
276
  export function buildMenuSections(settings: DecoratorSettings, bundledPacks: readonly WordPack[], userPacks: readonly WordPack[] = []): MenuSection[] {
289
277
  const packs = [...bundledPacks, ...userPacks];
278
+ const packItems = packs.map((pack) => ({ id: `${WORD_PACK_MENU_PREFIX}${pack.id}`, label: pack.name, value: isWordPackEnabled(pack.id, settings.wordPacks) }));
279
+ const packIds = packItems.map(item => item.id);
290
280
  return [
291
281
  buildSection("User Prompt", settings),
292
282
  buildSection("“Working” Loader", settings),
293
283
  { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
294
284
  buildSection("Completion Marker", settings),
295
- { title: "Word Packs", items: packs.map((pack) => ({ id: `pack:${pack.id}`, label: pack.name, value: isWordPackEnabled(pack.id, settings.wordPacks) })) },
285
+ {
286
+ title: "Word Packs",
287
+ items: [
288
+ ...packItems,
289
+ ...buildSection("Word Packs", settings).items.map((item, index) => ({ ...item, spacerBefore: index === 0, disabledUnlessAnyOf: packIds })),
290
+ ],
291
+ },
296
292
  buildSection("Options", settings),
297
293
  ];
298
294
  }
@@ -312,8 +308,8 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
312
308
  }
313
309
  }
314
310
  for (const [id, value] of Object.entries(values)) {
315
- if (!id.startsWith("pack:") || typeof value !== "boolean") continue;
316
- const packId = id.slice("pack:".length);
311
+ if (!id.startsWith(WORD_PACK_MENU_PREFIX) || typeof value !== "boolean") continue;
312
+ const packId = id.slice(WORD_PACK_MENU_PREFIX.length);
317
313
  if (isWordPackId(packId)) next.wordPacks[packId] = value;
318
314
  }
319
315
  if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]);
@@ -327,7 +323,8 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
327
323
  * don't resurface on re-enable.
328
324
  * Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and
329
325
  * `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker
330
- * border color is reset. A legacy `features.simCityWorkingText: true` enables the
326
+ * border color is reset. Below 4, the response-model footer inherits the response-model
327
+ * setting. A legacy `features.simCityWorkingText: true` enables the
331
328
  * `simcity` pack when no explicit preference is stored.
332
329
  */
333
330
  export function loadSettings(): DecoratorSettings {
@@ -344,6 +341,15 @@ export function loadSettings(): DecoratorSettings {
344
341
  wordPacks.simcity = true;
345
342
  }
346
343
  const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks };
344
+ if (isPlainObject(parsed.decorations)) {
345
+ for (const [key, value] of Object.entries(parsed.decorations)) {
346
+ if (!Object.hasOwn(DEFAULT_SETTINGS.decorations, key) || typeof value !== "string") continue;
347
+ const k = key as keyof DecorationSettings;
348
+ if (isDecorationBooleanKey(k)) continue;
349
+ setDecorationCycleValue(settings.decorations, k,
350
+ value === "default" && (k === "spinnerColor" || k === "doneMarkerBorderColor") ? "thinking-level" : value);
351
+ }
352
+ }
347
353
  const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
348
354
  if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) {
349
355
  settings.decorations.spinnerColor = "thinking-level";
@@ -352,6 +358,9 @@ export function loadSettings(): DecoratorSettings {
352
358
  if (schemaVersion < DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION) {
353
359
  settings.decorations.doneMarkerBorderColor = "thinking-level";
354
360
  }
361
+ if (schemaVersion < RESPONSE_MODEL_FOOTER_MIGRATION_VERSION) {
362
+ settings.features.responseModelFooter = settings.features.responseModel;
363
+ }
355
364
  for (const entry of MENU_ENTRIES) {
356
365
  if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
357
366
  setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
@@ -0,0 +1,12 @@
1
+ import { isPlainObject } from "./util.ts";
2
+
3
+ /**
4
+ * Event-bus channel on which pi-topping-statusline announces, as `{ embedded: boolean }`,
5
+ * whether its status bar hosts Pi's working loader. Shared with pi-topping-statusline.
6
+ */
7
+ export const STATUSLINE_EMBED_CHANNEL = "pi-topping-statusline:working-status-embedded";
8
+
9
+ /** Whether an announcement on STATUSLINE_EMBED_CHANNEL says the status bar hosts the loader. */
10
+ export function announcesEmbedded(data: unknown): boolean {
11
+ return isPlainObject(data) && data.embedded === true;
12
+ }
package/src/word-packs.ts CHANGED
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { isPlainObject, stripControlChars } from "./util.ts";
6
6
  import { WORDS, type WordEntry } from "./words.ts";
7
7
 
8
+ export const WORD_PACK_MENU_PREFIX = "pack:";
8
9
  const PACK_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
9
10
  const RESERVED_BUNDLED_PACK_IDS = new Set(["doctor-who", "firefly", "hitchhikers-guide", "lord-of-the-rings", "matrix", "portal", "simcity", "star-trek", "star-wars"]);
10
11
 
@@ -107,14 +108,22 @@ export function isWordPackEnabled(id: string, enabled: Record<string, boolean>):
107
108
  return enabled[id] === true;
108
109
  }
109
110
 
110
- export function selectWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[], fraction: number): WorkingTextSelection {
111
- const enabledWords = packs.filter((pack) => isWordPackEnabled(pack.id, enabled)).flatMap((pack) => pack.words);
112
- const total = WORDS.length + enabledWords.length;
113
- const idx = Math.min(total - 1, Math.floor(fraction * total));
114
- const entry = idx < WORDS.length ? WORDS[idx] : enabledWords[idx - WORDS.length];
115
- return { text: entry.present_tense, pastTense: entry.past_tense };
111
+ export function selectWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[], fraction: number, includeDefaultWorkingText: boolean): WorkingTextSelection {
112
+ const enabledPacks = packs.filter((pack) => isWordPackEnabled(pack.id, enabled));
113
+ const packWordCount = enabledPacks.reduce((sum, pack) => sum + pack.words.length, 0);
114
+ const baseWords: readonly WordEntry[] = includeDefaultWorkingText || packWordCount === 0 ? WORDS : [];
115
+ const total = baseWords.length + packWordCount;
116
+ let idx = Math.min(total - 1, Math.floor(fraction * total));
117
+ let entry: WordEntry | undefined = baseWords[idx];
118
+ idx -= baseWords.length;
119
+ for (const pack of enabledPacks) {
120
+ if (entry) break;
121
+ entry = pack.words[idx];
122
+ idx -= pack.words.length;
123
+ }
124
+ return { text: entry!.present_tense, pastTense: entry!.past_tense };
116
125
  }
117
126
 
118
- export function pickWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[]): WorkingTextSelection {
119
- return selectWorkingTextSelection(enabled, packs, Math.random());
127
+ export function pickWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[], includeDefaultWorkingText: boolean): WorkingTextSelection {
128
+ return selectWorkingTextSelection(enabled, packs, Math.random(), includeDefaultWorkingText);
120
129
  }