@underactive/pi-topping 0.3.1 → 0.4.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
@@ -2,17 +2,17 @@
2
2
 
3
3
  We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is a [Pi](https://github.com/earendil-works/pi-coding-agent) extension that adds some quality of life improvements to Pi's UX, while looking good in the process:
4
4
 
5
- **User Prompt** — high-vis bordered prompt box with configurable border color, pi icon toggle, and timestamp.
5
+ **User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, and timestamp.
6
6
 
7
- ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/demo_user_prompt.png)
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 Text** — animated spinner (with color choice), randomized activity word, shimmer (with direction), token activity meter (with color, direction, and dim toggle), elapsed timer, and output token display.
9
+ **“Working” Loader** — animated spinner (with color choice), randomized activity word, shimmer (with direction and speed), token activity meter (with color, direction, and dim toggle), elapsed timer, and output token display — all arrangeable left to right.
10
10
 
11
- ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, and token count](https://raw.githubusercontent.com/underactive/pi-topping/main/demo.gif)
11
+ ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, and token count](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo.gif)
12
12
 
13
- **Completion Marker** — end-of-turn marker with icon, randomized verb, and token consumption display. Hooks into Pi's `agent_settled` event.
13
+ **Completion Marker** — end-of-turn marker with icon, randomized verb, and token consumption display. 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.
14
14
 
15
- ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/demo_completion_marker.png)
15
+ ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_completion_marker.png)
16
16
 
17
17
  ## Settings
18
18
 
@@ -27,15 +27,17 @@ Run `/topping-settings` (TUI only) to customize your toppings:
27
27
  ╟─ User Prompt ──────────────────────────────────────────────╢
28
28
  ║ [■] High-vis prompt ON ║
29
29
  ║ [■] Border color ‹ accent › ║
30
+ ║ [■] Border style ‹ double › ║
30
31
  ║ [■] Pi icon ON ║
31
32
  ║ [■] Timestamp ON ║
32
33
  ║ ║
33
- ╟─ Working Loader Text ──────────────────────────────────────╢
34
+ ╟─ “Working” Loader ─────────────────────────────────────────╢
34
35
  ║ [■] Animated spinner ON ║
35
36
  ║ ▸ [■] Animated spinner color ‹ accent › ║
36
37
  ║ [■] Randomize "Working" text ON ║
37
38
  ║ [■] Text shimmer ON ║
38
39
  ║ [■] Text shimmer direction ‹ Left to Right › ║
40
+ ║ [■] Text shimmer speed ‹ Normal › ║
39
41
  ║ [■] Token activity monitor ON ║
40
42
  ║ [■] Token activity monitor color ‹ accent › ║
41
43
  ║ [■] Token activity monitor direction ‹ Left to Right › ║
@@ -43,19 +45,32 @@ Run `/topping-settings` (TUI only) to customize your toppings:
43
45
  ║ [■] Elapsed time since prompt ON ║
44
46
  ║ [■] Show output tokens ON ║
45
47
  ║ ║
48
+ ╟─ Elements Order ───────────────────────────────────────────╢
49
+ ║ [ ] Animated spinner ║
50
+ ║ [■] “Working” text ↑ ↓ ║
51
+ ║ [ ] Token activity monitor ║
52
+ ║ [ ] Elapsed time ║
53
+ ║ [ ] Output tokens ║
54
+ ║ ║
46
55
  ╟─ Completion Marker ────────────────────────────────────────╢
47
56
  ║ [■] Show completion marker ON ║
48
57
  ║ [■] Pi icon ON ║
49
58
  ║ [■] Randomize "Worked" text ON ║
50
59
  ║ [■] Tokens spent ON ║
60
+ ║ [■] Mid-turn inputs ON ║
51
61
  ║ ║
52
62
  ╟─ Options ──────────────────────────────────────────────────╢
53
63
  ║ [■] Use NerdFont icons ON ║
54
64
  ╟────────────────────────────────────────────────────────────╢
55
65
  ║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
56
- ╚═════════════════════════════════════════════════════┥ 1/9 ┝╝
66
+ ╚════════════════════════════════════════════════════┥ 1/28 ┝╝
57
67
  ```
58
68
 
69
+ Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
70
+ left or right within the loader. Elapsed time and output tokens share a single
71
+ `(3s · ↓ 84 tokens)` parenthetical whenever they end up next to each other, and split
72
+ into `(3s)` and `(↓ 84 tokens)` when they do not.
73
+
59
74
  ## Install
60
75
 
61
76
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.3.1",
4
- "description": "A Pi extension that decorates user prompts and replaces the default \"Working...\" loader with a shimmering, randomized activity word, live elapsed timer, and output-token activity meter",
3
+ "version": "0.4.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",
@@ -30,13 +30,13 @@
30
30
  "@earendil-works/pi-tui": "*"
31
31
  },
32
32
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "0.80.6",
34
- "@earendil-works/pi-tui": "0.80.6",
33
+ "@earendil-works/pi-coding-agent": "0.82.1",
34
+ "@earendil-works/pi-tui": "0.82.1",
35
35
  "@types/node": "^24.0.0",
36
36
  "typescript": "^5.9.3"
37
37
  },
38
38
  "pi": {
39
39
  "extensions": ["./index.ts"],
40
- "video": "https://cdn.jsdelivr.net/gh/underactive/pi-topping@main/demo.mp4"
40
+ "image": "https://raw.githubusercontent.com/underactive/pi-topping/main/media/working.png"
41
41
  }
42
42
  }
package/src/format.ts CHANGED
@@ -3,6 +3,18 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
3
3
  /** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
4
4
  export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
5
 
6
+ /** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
7
+ export const SPINNER_FRAME_MS = 80;
8
+
9
+ /** A user-orderable piece of the working indicator. */
10
+ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens";
11
+
12
+ /** Left-to-right element order matching pi's stock working indicator. */
13
+ export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "elapsed", "tokens"];
14
+
15
+ /** Elements rendered inside the dim parenthetical rather than as standalone segments. */
16
+ const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens"]);
17
+
6
18
  /** Format an output-token count for the working indicator. */
7
19
  export function formatTokens(count: number): string {
8
20
  if (count < 1000) return count.toString();
@@ -52,19 +64,37 @@ export function isFullyDefaultAppearance(features: {
52
64
  return !features.substituteDefaultMessage && !decorations.shimmer && !decorations.tokenActivityMonitor && !features.elapsedTime && !features.outputTokens;
53
65
  }
54
66
 
55
- /** Assemble the styled word, meter, and optional detail fields. */
67
+ /**
68
+ * Assemble the working indicator from already-styled pieces, laid out in `order`.
69
+ *
70
+ * Empty/omitted pieces are dropped, and any run of detail elements left adjacent
71
+ * after that collapses into a single dim parenthetical.
72
+ */
56
73
  export function buildWorkingMessage(
57
74
  theme: Pick<Theme, "fg">,
58
- word: string,
59
- frame: string,
60
- elapsed: string,
61
- tokens: string,
62
- features: { elapsedTime: boolean; outputTokens: boolean },
75
+ parts: Partial<Record<LoaderElement, string>>,
76
+ order: readonly LoaderElement[] = DEFAULT_LOADER_ORDER,
63
77
  ): string {
64
- const parts = [word, frame].filter(Boolean);
65
- const details = [features.elapsedTime ? elapsed : "", features.outputTokens ? `↓ ${tokens} tokens` : ""].filter(Boolean);
66
- if (details.length) parts.push(theme.fg("dim", `(${details.join(" · ")})`));
67
- return parts.join(" ");
78
+ const segments: string[] = [];
79
+ let details: string[] = [];
80
+ const flushDetails = (): void => {
81
+ if (details.length) segments.push(theme.fg("dim", `(${details.join(" · ")})`));
82
+ details = [];
83
+ };
84
+
85
+ for (const element of order) {
86
+ const value = parts[element];
87
+ if (!value) continue;
88
+ if (DETAIL_ELEMENTS.has(element)) {
89
+ details.push(value);
90
+ continue;
91
+ }
92
+ flushDetails();
93
+ segments.push(value);
94
+ }
95
+ flushDetails();
96
+
97
+ return segments.join(" ");
68
98
  }
69
99
 
70
100
  export function shimmerString(
@@ -72,6 +102,7 @@ export function shimmerString(
72
102
  elapsedMs: number,
73
103
  theme: Pick<Theme, "getFgAnsi">,
74
104
  direction: "ltr" | "rtl" = "ltr",
105
+ speed: "slow" | "normal" | "fast" = "normal",
75
106
  ): string {
76
107
  const chars = [...text];
77
108
  if (chars.length === 0) return "";
@@ -81,9 +112,21 @@ export function shimmerString(
81
112
  const SHIMMER_BASE = ansiToRgb(theme.getFgAnsi("dim"));
82
113
  const SHIMMER_HIGHLIGHT = ansiToRgb(theme.getFgAnsi("text"));
83
114
  const period = chars.length + SHIMMER_PADDING * 2;
84
- const elapsedS = elapsedMs / 1000;
85
- const sweep = ((elapsedS % SHIMMER_SWEEP_S) / SHIMMER_SWEEP_S) * period;
86
- const pos = direction === "rtl" ? period - sweep : sweep;
115
+ const unitsPerS = period / SHIMMER_SWEEP_S;
116
+ // The band crosses padding at either end while every character is still dim. Scaling only
117
+ // the stretch where it actually overlaps the text keeps that dark pause identical at every
118
+ // speed, so `speed` changes the sweep alone rather than the whole cycle.
119
+ const litEnter = SHIMMER_PADDING - SHIMMER_BAND_HALF;
120
+ const litExit = SHIMMER_PADDING + chars.length - 1 + SHIMMER_BAND_HALF;
121
+ const enterS = litEnter / unitsPerS;
122
+ const litS = (litExit - litEnter) / unitsPerS / (speed === "slow" ? 0.5 : speed === "fast" ? 2 : 1);
123
+ const phase = (elapsedMs / 1000) % (enterS + litS + (period - litExit) / unitsPerS);
124
+ const linear = phase < enterS
125
+ ? phase * unitsPerS
126
+ : phase < enterS + litS
127
+ ? litEnter + ((phase - enterS) / litS) * (litExit - litEnter)
128
+ : litExit + (phase - enterS - litS) * unitsPerS;
129
+ const pos = direction === "rtl" ? period - linear : linear;
87
130
 
88
131
  return chars
89
132
  .map((ch, i) => {
package/src/menu.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  * extensions, built on top of `@earendil-works/pi-tui`'s `Component`
6
6
  * contract and `ctx.ui.custom()` overlay API.
7
7
  *
8
- * Renders a titled box containing one or more sections of boolean toggle
9
- * or multi-value cycle items, plus an optional live-updating preview section driven by a
10
- * caller-supplied render callback, e.g.:
8
+ * Renders a titled box containing one or more sections of boolean toggle,
9
+ * multi-value cycle, or drag-to-reorder items, plus an optional live-updating
10
+ * preview section driven by a caller-supplied render callback, e.g.:
11
11
  *
12
12
  * ╔═[ Pi Topping: Settings ]════════════════════════╗
13
13
  * ╟─ Preview ─────────────────────────────────────╢
@@ -49,6 +49,13 @@ export interface MenuItem {
49
49
  cycleEnabled?: boolean;
50
50
  /** Value snapped to when the gating checkbox is unchecked. */
51
51
  cycleDisabledValue?: string;
52
+ /**
53
+ * Marks the item as a reorderable row. Space grabs/releases it; while grabbed,
54
+ * up/down move it among the other rows sharing this group instead of moving the
55
+ * cursor. The group's current order is published as a comma-joined list of item
56
+ * ids under this key in the result values.
57
+ */
58
+ reorderGroup?: string;
52
59
  }
53
60
 
54
61
  export interface MenuSection {
@@ -90,18 +97,26 @@ interface FlatItem {
90
97
  cycleValues?: readonly string[];
91
98
  cycleEnabledBy?: string;
92
99
  cycleDisabledValue?: string;
100
+ reorderGroup?: string;
93
101
  item: MenuItem;
94
102
  sectionIndex: number;
95
103
  }
96
104
 
97
105
  function buildInitialValues(config: MenuConfig): Record<string, MenuValue> {
98
106
  const values: Record<string, MenuValue> = {};
107
+ const reorderGroups = new Map<string, string[]>();
99
108
  for (const section of config.sections) {
100
109
  for (const item of section.items) {
101
110
  values[item.id] = item.value;
102
111
  if (item.cycleEnabledBy) values[item.cycleEnabledBy] = item.cycleEnabled ?? true;
112
+ if (item.reorderGroup) {
113
+ const ids = reorderGroups.get(item.reorderGroup) ?? [];
114
+ ids.push(item.id);
115
+ reorderGroups.set(item.reorderGroup, ids);
116
+ }
103
117
  }
104
118
  }
119
+ for (const [group, ids] of reorderGroups) values[group] = ids.join(",");
105
120
  return values;
106
121
  }
107
122
 
@@ -144,7 +159,7 @@ export class MenuComponent implements Component {
144
159
  this.flat = [];
145
160
  for (const [sectionIndex, section] of config.sections.entries()) {
146
161
  for (const item of section.items) {
147
- this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, item, sectionIndex });
162
+ this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, reorderGroup: item.reorderGroup, item, sectionIndex });
148
163
  }
149
164
  }
150
165
  this.initialValues = { ...this.values };
@@ -191,10 +206,12 @@ export class MenuComponent implements Component {
191
206
 
192
207
  const keyActions: Record<string, () => void> = {
193
208
  [Key.up]: () => {
209
+ if (this.moveGrabbedItem(-1)) return;
194
210
  this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
195
211
  this.invalidate();
196
212
  },
197
213
  [Key.down]: () => {
214
+ if (this.moveGrabbedItem(1)) return;
198
215
  this.cursor = (this.cursor + 1) % this.flat.length;
199
216
  this.invalidate();
200
217
  },
@@ -233,6 +250,32 @@ export class MenuComponent implements Component {
233
250
  return lines;
234
251
  }
235
252
 
253
+ /**
254
+ * Move the grabbed reorder row one slot within its group, clamped at the group
255
+ * edges. Returns true when the key belongs to the mover and must not also move
256
+ * the cursor — which is what keeps a grab from escaping its group.
257
+ */
258
+ private moveGrabbedItem(delta: number): boolean {
259
+ const grabbed = this.flat[this.cursor]!;
260
+ const group = grabbed.reorderGroup;
261
+ if (group === undefined || this.values[grabbed.id] !== true) return false;
262
+
263
+ let start = this.cursor;
264
+ while (start > 0 && this.flat[start - 1]!.reorderGroup === group) start--;
265
+ let end = this.cursor;
266
+ while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
267
+
268
+ const target = this.cursor + delta;
269
+ if (target >= start && target <= end) {
270
+ this.flat[this.cursor] = this.flat[target]!;
271
+ this.flat[target] = grabbed;
272
+ this.cursor = target;
273
+ this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
274
+ this.invalidate();
275
+ }
276
+ return true;
277
+ }
278
+
236
279
  private cycleCurrentValue(delta: number): void {
237
280
  const item = this.flat[this.cursor]!;
238
281
  if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
@@ -279,13 +322,20 @@ export class MenuComponent implements Component {
279
322
  return [this.renderSectionDivider("Preview", innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
280
323
  }
281
324
 
325
+ /** Render every section from `this.flat`, the single source of truth for row order. */
282
326
  private buildToggleSections(innerWidth: number): string[] {
283
- let flatIndex = 0;
284
- return this.sections.flatMap(section => [
285
- this.renderSectionDivider(section.title, innerWidth),
286
- ...section.items.map(item => this.renderItemRow(item, flatIndex++ === this.cursor, innerWidth)),
287
- this.renderBlankRow(innerWidth),
288
- ]);
327
+ const lines: string[] = [];
328
+ let currentSection = -1;
329
+ for (const [index, flat] of this.flat.entries()) {
330
+ if (flat.sectionIndex !== currentSection) {
331
+ if (currentSection !== -1) lines.push(this.renderBlankRow(innerWidth));
332
+ lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
333
+ currentSection = flat.sectionIndex;
334
+ }
335
+ lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
336
+ }
337
+ if (lines.length) lines.push(this.renderBlankRow(innerWidth));
338
+ return lines;
289
339
  }
290
340
 
291
341
  /** Build a section-aware item window, keeping a divider above each visible section. */
@@ -446,6 +496,19 @@ export class MenuComponent implements Component {
446
496
  const marker = selected ? "\u25b8" : " ";
447
497
  const markerColored = selected ? th.fg("accent", marker) : marker;
448
498
 
499
+ if (item.reorderGroup) {
500
+ const held = value as boolean;
501
+ const stateWord = held ? "\u2191 \u2193" : "";
502
+ const rightPlain = `${stateWord} `;
503
+ const fixedLeftLen = 8;
504
+ const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
505
+ const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
506
+ const leftPlain = ` ${marker} [${held ? "\u25a0" : " "}] ${label}`;
507
+ const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
508
+ const content = ` ${markerColored} [${held ? th.fg("accent", "\u25a0") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${held ? th.fg("accent", stateWord) : ""} `;
509
+ return this.wrap("\u2551", content, "\u2551");
510
+ }
511
+
449
512
  if (item.cycleValues) {
450
513
  const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
451
514
  const stateWord = `‹ ${value} ›`;
package/src/preview.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { PreviewResult } from "./menu.ts";
3
3
  import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
4
- import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, SPINNER_FRAMES } from "./format.ts";
4
+ import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
5
5
  import { buildPromptBoxLines } from "./prompt-decorator.ts";
6
+ import { LOADER_ORDER_ID, parseLoaderOrder } from "./settings.ts";
6
7
  import { pickRandomWord } from "./words.ts";
7
8
  const METER_INTERVAL_MS = 100, METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
8
9
  const DEFAULT_WORKING_WORD = "Working…";
9
- const PROMPT_IDS = new Set(["decorateUserPrompt", "borderColor", "promptIcon", "promptTimestamp"]);
10
- const MARKER_IDS = new Set(["doneMarker", "doneMarkerIcon", "randomizeDoneMarker", "doneMarkerTokens"]);
10
+ const PROMPT_IDS = new Set(["decorateUserPrompt", "borderColor", "borderStyle", "promptIcon", "promptTimestamp"]);
11
+ const MARKER_IDS = new Set(["doneMarker", "doneMarkerIcon", "randomizeDoneMarker", "doneMarkerTokens", "doneMarkerInputs"]);
11
12
  function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
12
13
 
13
14
  /** Stateful, per-menu preview renderer that follows the active settings section. */
@@ -27,24 +28,33 @@ export class PreviewRenderer {
27
28
  const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false };
28
29
  const decorations = { shimmer: values.shimmer !== false, tokenActivityMonitor: values.tokenActivityMonitor !== false };
29
30
  const spinnerColor = (values.spinnerColorEnabled === false ? "accent" : values.spinnerColor === "border" || values.spinnerColor === "borderAccent" ? values.spinnerColor : "accent") as "accent" | "border" | "borderAccent";
30
- const spinner = values.animatedSpinner ? this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / 80) % SPINNER_FRAMES.length]!) : "";
31
- if (isFullyDefaultAppearance(features, decorations)) return [spinner, this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD)].filter(Boolean).join(" ");
31
+ const spinner = values.animatedSpinner ? this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!) : "";
32
+ const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
33
+ if (isFullyDefaultAppearance(features, decorations)) return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, order);
32
34
  const word = features.substituteDefaultMessage ? this.#word : DEFAULT_WORKING_WORD;
33
- const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme, values.shimmerDirection === "Right to Left" ? "rtl" : "ltr") : this.#ctx.ui.theme.fg("text", word);
35
+ const styledWord = decorations.shimmer ? shimmerString(word, elapsedMs, this.#ctx.ui.theme, values.shimmerDirection === "Right to Left" ? "rtl" : "ltr", values.shimmerSpeed === "Slow" ? "slow" : values.shimmerSpeed === "Fast" ? "fast" : "normal") : this.#ctx.ui.theme.fg("text", word);
34
36
  const meterColor = (values.meterColorEnabled === false ? "accent" : values.meterColor === "border" || values.meterColor === "borderAccent" ? values.meterColor : "accent") as "accent" | "border" | "borderAccent";
35
37
  const meter = decorations.tokenActivityMonitor ? this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColor, values.meterDimmed !== false)) : "";
36
- return [spinner, buildWorkingMessage(this.#ctx.ui.theme, styledWord, meter, formatElapsed(elapsedMs), formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC))), features)].filter(Boolean).join(" ");
38
+ return buildWorkingMessage(this.#ctx.ui.theme, {
39
+ spinner,
40
+ text: styledWord,
41
+ meter,
42
+ elapsed: features.elapsedTime ? formatElapsed(elapsedMs) : "",
43
+ tokens: features.outputTokens ? `↓ ${formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)))} tokens` : "",
44
+ }, order);
37
45
  }
38
46
  private promptPreview(values: Record<string, boolean | string>): PreviewResult {
39
47
  if (!values.decorateUserPrompt) return { lines: ["User prompt decoration is disabled."] };
40
48
 
41
49
  const borderColor = values.borderColor;
50
+ const borderStyle = values.borderStyle;
42
51
  const timestamp = values.promptTimestamp ? Date.now() : undefined;
43
52
  const lines = buildPromptBoxLines("ping", timestamp, 70, this.#ctx.ui.theme, {
44
53
  showIcon: values.promptIcon as boolean,
45
54
  showTimestamp: values.promptTimestamp as boolean,
46
55
  icon: values.useNerdFont ? "" : "π",
47
56
  borderColor: borderColor === "accent" || borderColor === "border" || borderColor === "borderAccent" ? borderColor : "accent",
57
+ borderStyle: borderStyle === "double" || borderStyle === "single" || borderStyle === "rounded" || borderStyle === "heavy" ? borderStyle : "double",
48
58
  });
49
59
  return timestamp === undefined
50
60
  ? { lines }
@@ -55,7 +65,10 @@ export class PreviewRenderer {
55
65
  const th = this.#ctx.ui.theme;
56
66
  const icon = values.doneMarkerIcon ? `${values.useNerdFont ? "" : "π"} ` : "";
57
67
  const word = (values.randomizeDoneMarker ? "Concocted" : "Worked") as string;
58
- const tail = (values.doneMarkerTokens ? " (↓ 949 tokens)" : "") as string;
68
+ const details: string[] = [];
69
+ if (values.doneMarkerTokens) details.push("↓ 949 tokens");
70
+ if (values.doneMarkerInputs) details.push("2 mid-turn inputs");
71
+ const tail = details.length ? ` (${details.join(" · ")})` : "";
59
72
  return { lines: ["", `${values.doneMarkerIcon ? th.fg("text", icon) : ""}${th.fg("dim", `${word} for 2m 52s${tail}`)}`, ""] };
60
73
  }
61
74
  }
@@ -3,12 +3,22 @@ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works
3
3
 
4
4
  export const PROMPT_BOX_TYPE = "pi-topping-prompt";
5
5
 
6
+ export type BorderStyle = "double" | "single" | "rounded" | "heavy";
7
+
8
+ const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
9
+ double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
10
+ single: { tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│" },
11
+ rounded: { tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│" },
12
+ heavy: { tl: "┏", tr: "┓", bl: "┗", br: "┛", h: "━", v: "┃" },
13
+ };
14
+
6
15
  export interface PromptBoxDetails {
7
16
  submittedAt?: number;
8
17
  showIcon?: boolean;
9
18
  showTimestamp?: boolean;
10
19
  icon?: string;
11
20
  borderColor?: "accent" | "border" | "borderAccent";
21
+ borderStyle?: BorderStyle;
12
22
  }
13
23
 
14
24
  type PromptTheme = { fg(color: string, text: string): string };
@@ -23,6 +33,7 @@ export function buildPromptBoxLines(
23
33
  ): string[] {
24
34
  if (width < 10) return [];
25
35
 
36
+ const g = BORDER_GLYPHS[options.borderStyle ?? "double"];
26
37
  const border = (text: string) => theme.fg(options.borderColor ?? "accent", text);
27
38
  const label = (text: string) => theme.fg("customMessageLabel", text);
28
39
  const muted = (text: string) => theme.fg("dim", text);
@@ -41,7 +52,7 @@ export function buildPromptBoxLines(
41
52
  const titleLength = visibleWidth(title);
42
53
  const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
43
54
  const topFill = Math.max(0, innerWidth - titleLength - timeLength);
44
- const topLine = `${border("╔")}${icon ? `${border("══ ")}${label(icon)}${border(" ")}` : ""}${border("═".repeat(topFill))}${time ? `${border(" ")}${muted(time)}${border(" ═")}` : ""}${border("╗")}`;
55
+ const topLine = `${border(g.tl)}${icon ? `${border(`${g.h}${g.h} `)}${label(icon)}${border(" ")}` : ""}${border(g.h.repeat(topFill))}${time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : ""}${border(g.tr)}`;
45
56
  const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
46
57
 
47
58
  if (content) {
@@ -49,12 +60,12 @@ export function buildPromptBoxLines(
49
60
  for (const rawLine of wrapTextWithAnsi(content, textWidth)) {
50
61
  const displayLine = rawLine || " ";
51
62
  const padded = `${displayLine}${" ".repeat(Math.max(0, textWidth - visibleWidth(displayLine)))}`;
52
- const line = `${border("║")}${bodyText(` ${padded} `)}${border("║")}`;
63
+ const line = `${border(g.v)}${bodyText(` ${padded} `)}${border(g.v)}`;
53
64
  lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
54
65
  }
55
66
  }
56
67
 
57
- const bottomLine = `${border("╚")}${border("═".repeat(innerWidth))}${border("╝")}`;
68
+ const bottomLine = `${border(g.bl)}${border(g.h.repeat(innerWidth))}${border(g.br)}`;
58
69
  lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
59
70
  return lines;
60
71
  }
package/src/session.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  formatTokens,
19
19
  isFullyDefaultAppearance,
20
20
  shimmerString,
21
+ SPINNER_FRAME_MS,
21
22
  SPINNER_FRAMES,
22
23
  StreamingWordCounter,
23
24
  } from "./format.ts";
@@ -41,7 +42,8 @@ const ELAPSED_INTERVAL_MS = 1_000;
41
42
  export interface DoneEntryData {
42
43
  word: string;
43
44
  elapsedMs: number;
44
- tokens: number;
45
+ tokens?: number;
46
+ midTurnInputs?: number;
45
47
  }
46
48
 
47
49
  export interface SessionState {
@@ -55,6 +57,7 @@ export interface SessionState {
55
57
  lastActivityMeterUpdate: number;
56
58
  timer: ReturnType<typeof setInterval> | null;
57
59
  busy: boolean;
60
+ midTurnInputs: number;
58
61
  }
59
62
 
60
63
  export function makeFreshState(): SessionState {
@@ -69,6 +72,7 @@ export function makeFreshState(): SessionState {
69
72
  lastActivityMeterUpdate: 0,
70
73
  timer: null,
71
74
  busy: false,
75
+ midTurnInputs: 0,
72
76
  };
73
77
  }
74
78
 
@@ -99,7 +103,8 @@ export class SessionManager {
99
103
  this.#currentCtx = ctx;
100
104
  if (!this.usable(ctx)) return;
101
105
 
102
- this.resetTurn(Date.now());
106
+ if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
107
+ else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
103
108
  if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
104
109
  this.#pi.sendMessage<PromptBoxDetails>(
105
110
  {
@@ -112,6 +117,7 @@ export class SessionManager {
112
117
  showTimestamp: this.#settings.decorations.promptTimestamp,
113
118
  icon: this.#settings.decorations.useNerdFont ? "" : "π",
114
119
  borderColor: this.#settings.decorations.borderColor,
120
+ borderStyle: this.#settings.decorations.borderStyle,
115
121
  },
116
122
  },
117
123
  {
@@ -195,6 +201,7 @@ export class SessionManager {
195
201
  word: this.#settings.features.randomizeDoneMarker ? pickRawWord().past_tense : "Worked",
196
202
  elapsedMs,
197
203
  tokens: this.#state.confirmTokens,
204
+ midTurnInputs: this.#state.midTurnInputs,
198
205
  });
199
206
  }
200
207
  this.#currentCtx = null;
@@ -214,13 +221,19 @@ export class SessionManager {
214
221
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
215
222
  this.#pi.on("agent_settled", this.#onAgentSettled);
216
223
  this.#pi.on("session_shutdown", this.#onSessionShutdown);
217
- this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) =>
218
- entry.data
219
- ? new Text(
220
- `${this.#settings.features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${this.#settings.features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${this.#settings.features.doneMarkerTokens && entry.data.tokens !== undefined ? ` (↓ ${formatTokens(entry.data.tokens)} tokens)` : ""}`)}`,
221
- )
222
- : undefined,
223
- );
224
+ this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => {
225
+ if (!entry.data) return undefined;
226
+ const features = this.#settings.features;
227
+ const details: string[] = [];
228
+ if (features.doneMarkerTokens && entry.data.tokens !== undefined) details.push(`↓ ${formatTokens(entry.data.tokens)} tokens`);
229
+ if (features.doneMarkerInputs && entry.data.midTurnInputs) {
230
+ details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
231
+ }
232
+ const tail = details.length ? ` (${details.join(" · ")})` : "";
233
+ return new Text(
234
+ `${features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : ""}${theme.fg("dim", `${features.doneMarkerIcon ? " " : ""}${entry.data.word} for ${formatElapsed(entry.data.elapsedMs)}${tail}`)}`,
235
+ );
236
+ });
224
237
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
225
238
  this.#pi.registerCommand("topping-settings", {
226
239
  description: "Configure pi-topping's spinner, shimmer, activity meter, and message details.",
@@ -236,14 +249,31 @@ export class SessionManager {
236
249
  return event.source !== "extension" && !!event.text.trim() && !event.images?.length && !/^[\/!?:]/.test(event.text);
237
250
  }
238
251
 
239
- private applyIndicator(ctx: ExtensionContext): void {
252
+ /**
253
+ * Pi's Loader always prepends its indicator to the working message, so a spinner
254
+ * that is not the leading element has to be drawn inside the message instead.
255
+ */
256
+ private spinnerInMessage(): boolean {
257
+ return this.#settings.decorations.animatedSpinner && this.#settings.loaderOrder[0] !== "spinner";
258
+ }
259
+
260
+ private spinnerColor(): "accent" | "border" | "borderAccent" {
240
261
  const decorations = this.#settings.decorations;
241
- if (!decorations.animatedSpinner) {
262
+ return decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
263
+ }
264
+
265
+ private indicatorFingerprint(): string {
266
+ const decorations = this.#settings.decorations;
267
+ return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
268
+ }
269
+
270
+ private applyIndicator(ctx: ExtensionContext): void {
271
+ if (!this.#settings.decorations.animatedSpinner || this.spinnerInMessage()) {
242
272
  ctx.ui.setWorkingIndicator({ frames: [] });
243
273
  return;
244
274
  }
245
275
 
246
- const color = decorations.spinnerColorEnabled ? decorations.spinnerColor : "accent";
276
+ const color = this.spinnerColor();
247
277
  if (color === "accent") {
248
278
  ctx.ui.setWorkingIndicator(undefined);
249
279
  return;
@@ -263,13 +293,14 @@ export class SessionManager {
263
293
 
264
294
  const features = this.#settings.features;
265
295
  const decorations = this.#settings.decorations;
266
- const interval = decorations.shimmer
296
+ let interval = decorations.shimmer
267
297
  ? SHIMMER_INTERVAL
268
298
  : (decorations.tokenActivityMonitor || features.outputTokens)
269
299
  ? METER_INTERVAL_MS
270
300
  : features.elapsedTime
271
301
  ? ELAPSED_INTERVAL_MS
272
302
  : undefined;
303
+ if (this.spinnerInMessage()) interval = Math.min(interval ?? SPINNER_FRAME_MS, SPINNER_FRAME_MS);
273
304
  if (interval) this.#state.timer = setInterval(() => this.tick(), interval);
274
305
  }
275
306
 
@@ -283,6 +314,7 @@ export class SessionManager {
283
314
  state.activityMeter.reset();
284
315
  state.rateTracker.reset();
285
316
  state.lastActivityMeterUpdate = 0;
317
+ state.midTurnInputs = 0;
286
318
  this.#counter.reset();
287
319
  }
288
320
 
@@ -299,14 +331,21 @@ export class SessionManager {
299
331
  state.activityMeter.push(rateToLevel(state.rateTracker.sample(total, now)));
300
332
  state.lastActivityMeterUpdate = now;
301
333
  }
334
+ const spinner = this.spinnerInMessage()
335
+ ? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
336
+ : "";
302
337
  if (isFullyDefaultAppearance(features, decorations)) {
303
- ctx.ui.setWorkingMessage();
338
+ ctx.ui.setWorkingMessage(
339
+ spinner
340
+ ? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
341
+ : undefined,
342
+ );
304
343
  return;
305
344
  }
306
345
 
307
346
  const word = features.substituteDefaultMessage ? state.currentWord : DEFAULT_WORKING_WORD;
308
347
  const styled = decorations.shimmer
309
- ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection)
348
+ ? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed)
310
349
  : ctx.ui.theme.fg("text", word);
311
350
  const meter = decorations.tokenActivityMonitor
312
351
  ? state.activityMeter.render((level, char) =>
@@ -322,11 +361,14 @@ export class SessionManager {
322
361
  ctx.ui.setWorkingMessage(
323
362
  buildWorkingMessage(
324
363
  ctx.ui.theme,
325
- styled,
326
- meter,
327
- formatElapsed(now - state.startTime),
328
- formatTokens(total),
329
- features,
364
+ {
365
+ spinner,
366
+ text: styled,
367
+ meter,
368
+ elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
369
+ tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
370
+ },
371
+ this.#settings.loaderOrder,
330
372
  ),
331
373
  );
332
374
  }
@@ -337,7 +379,7 @@ export class SessionManager {
337
379
  return;
338
380
  }
339
381
 
340
- const before = `${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`;
382
+ const before = this.indicatorFingerprint();
341
383
  const preview = new PreviewRenderer(ctx);
342
384
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
343
385
  title: "Pi Topping: Settings",
@@ -356,12 +398,7 @@ export class SessionManager {
356
398
  }
357
399
 
358
400
  this.#settings = updatedSettings;
359
- if (
360
- before !==
361
- `${this.#settings.decorations.animatedSpinner}:${this.#settings.decorations.spinnerColor}:${this.#settings.decorations.spinnerColorEnabled}`
362
- ) {
363
- this.applyIndicator(ctx);
364
- }
401
+ if (before !== this.indicatorFingerprint()) this.applyIndicator(ctx);
365
402
  this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
366
403
  if (this.#state.busy) {
367
404
  this.stopTimer();
package/src/settings.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
4
5
  import type { MenuSection } from "./menu.ts";
5
6
 
6
7
  export interface DecoratorSettings {
@@ -9,12 +10,16 @@ export interface DecoratorSettings {
9
10
  shimmer: boolean;
10
11
  shimmerDirection: "ltr" | "rtl";
11
12
  shimmerDirectionEnabled: boolean;
13
+ shimmerSpeed: "slow" | "normal" | "fast";
14
+ shimmerSpeedEnabled: boolean;
12
15
  tokenActivityMonitor: boolean;
13
16
  meterDirection: "ltr" | "rtl";
14
17
  meterDirectionEnabled: boolean;
15
18
  decorateUserPrompt: boolean;
16
19
  borderColor: "accent" | "border" | "borderAccent";
17
20
  borderColorEnabled: boolean;
21
+ borderStyle: "double" | "single" | "rounded" | "heavy";
22
+ borderStyleEnabled: boolean;
18
23
  spinnerColor: "accent" | "border" | "borderAccent";
19
24
  spinnerColorEnabled: boolean;
20
25
  meterColor: "accent" | "border" | "borderAccent";
@@ -32,14 +37,43 @@ export interface DecoratorSettings {
32
37
  doneMarkerIcon: boolean;
33
38
  randomizeDoneMarker: boolean;
34
39
  doneMarkerTokens: boolean;
40
+ doneMarkerInputs: boolean;
35
41
  };
42
+ loaderOrder: LoaderElement[];
36
43
  }
37
44
 
38
45
  export const DEFAULT_SETTINGS: DecoratorSettings = {
39
- decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, tokenActivityMonitor: true, meterDirection: "ltr", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "accent", borderColorEnabled: true, spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, promptIcon: true, promptTimestamp: true, useNerdFont: true },
40
- features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true },
46
+ decorations: { animatedSpinner: true, shimmer: true, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "ltr", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "accent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, promptIcon: true, promptTimestamp: true, useNerdFont: true },
47
+ features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
48
+ loaderOrder: [...DEFAULT_LOADER_ORDER],
41
49
  };
42
50
 
51
+ /** Menu key carrying the loader element order as a comma-joined list of element ids. */
52
+ export const LOADER_ORDER_ID = "loaderOrder";
53
+ const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
54
+ spinner: "Animated spinner",
55
+ text: "“Working” text",
56
+ meter: "Token activity monitor",
57
+ elapsed: "Elapsed time",
58
+ tokens: "Output tokens",
59
+ };
60
+
61
+ /**
62
+ * Normalize an element order from disk or from the menu, tolerating hand edits and
63
+ * version skew: unknown entries and duplicates are dropped, missing elements are
64
+ * appended in their default positions.
65
+ */
66
+ export function parseLoaderOrder(value: unknown): LoaderElement[] {
67
+ const entries = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value : [];
68
+ const order: LoaderElement[] = [];
69
+ for (const entry of entries) {
70
+ const element = typeof entry === "string" ? entry.trim() as LoaderElement : undefined;
71
+ if (element && DEFAULT_LOADER_ORDER.includes(element) && !order.includes(element)) order.push(element);
72
+ }
73
+ for (const element of DEFAULT_LOADER_ORDER) if (!order.includes(element)) order.push(element);
74
+ return order;
75
+ }
76
+
43
77
  export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
44
78
  function isPlainObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
45
79
  function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, parsed: unknown): T {
@@ -49,15 +83,20 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
49
83
  if (!(key in merged)) continue;
50
84
  if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
51
85
  if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) (merged as Record<string, boolean | string>)[key] = value;
86
+ if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) (merged as Record<string, boolean | string>)[key] = value;
52
87
  if (key.endsWith("Color") && (value === "accent" || value === "border" || value === "borderAccent")) (merged as Record<string, boolean | string>)[key] = value;
88
+ if (key === "borderStyle" && (value === "double" || value === "single" || value === "rounded" || value === "heavy")) (merged as Record<string, boolean | string>)[key] = value;
53
89
  }
54
90
  return merged;
55
91
  }
56
92
 
57
- /** Map a persisted setting value to its menu cycle label (directions only; colors pass through). */
93
+ /** Map a persisted setting value to its menu cycle label (directions and speeds only; colors pass through). */
58
94
  function toCycleValue(stored: unknown): string {
59
95
  if (stored === "ltr") return "Left to Right";
60
96
  if (stored === "rtl") return "Right to Left";
97
+ if (stored === "slow") return "Slow";
98
+ if (stored === "normal") return "Normal";
99
+ if (stored === "fast") return "Fast";
61
100
  return String(stored);
62
101
  }
63
102
 
@@ -65,10 +104,13 @@ function toCycleValue(stored: unknown): string {
65
104
  function fromCycleValue(value: string): string {
66
105
  if (value === "Left to Right") return "ltr";
67
106
  if (value === "Right to Left") return "rtl";
107
+ if (value === "Slow") return "slow";
108
+ if (value === "Normal") return "normal";
109
+ if (value === "Fast") return "fast";
68
110
  return value;
69
111
  }
70
112
 
71
- type MenuSectionName = "User Prompt" | "Working Loader Text" | "Completion Marker" | "Options";
113
+ type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
72
114
  type DecorationSettings = DecoratorSettings["decorations"];
73
115
  type FeatureSettings = DecoratorSettings["features"];
74
116
  type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
@@ -79,23 +121,26 @@ type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
79
121
  export const MENU_ENTRIES: readonly MenuEntry[] = [
80
122
  { id: "decorateUserPrompt", label: "High-vis prompt", section: "User Prompt", group: "decorations", key: "decorateUserPrompt" },
81
123
  { id: "borderColor", label: "Border color", section: "User Prompt", group: "decorations", key: "borderColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "borderColorEnabled", cycleDisabledValue: "border" },
124
+ { id: "borderStyle", label: "Border style", section: "User Prompt", group: "decorations", key: "borderStyle", cycleValues: ["double", "single", "rounded", "heavy"], cycleEnabledBy: "borderStyleEnabled", cycleDisabledValue: "double" },
82
125
  { id: "promptIcon", label: "Pi icon", section: "User Prompt", group: "decorations", key: "promptIcon" },
83
126
  { id: "promptTimestamp", label: "Timestamp", section: "User Prompt", group: "decorations", key: "promptTimestamp" },
84
- { id: "animatedSpinner", label: "Animated spinner", section: "Working Loader Text", group: "decorations", key: "animatedSpinner" },
85
- { id: "spinnerColor", label: "Animated spinner color", section: "Working Loader Text", group: "decorations", key: "spinnerColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
86
- { id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "Working Loader Text", group: "features", key: "substituteDefaultMessage" },
87
- { id: "shimmer", label: "Text shimmer", section: "Working Loader Text", group: "decorations", key: "shimmer" },
88
- { id: "shimmerDirection", label: "Text shimmer direction", section: "Working Loader Text", group: "decorations", key: "shimmerDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "Left to Right" },
89
- { id: "tokenActivityMonitor", label: "Token activity monitor", section: "Working Loader Text", group: "decorations", key: "tokenActivityMonitor" },
90
- { id: "meterColor", label: "Token activity monitor color", section: "Working Loader Text", group: "decorations", key: "meterColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
91
- { id: "meterDirection", label: "Token activity monitor direction", section: "Working Loader Text", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Left to Right" },
92
- { id: "meterDimmed", label: "Token activity monitor dimmed", section: "Working Loader Text", group: "decorations", key: "meterDimmed" },
93
- { id: "elapsedTime", label: "Elapsed time since prompt", section: "Working Loader Text", group: "features", key: "elapsedTime" },
94
- { id: "outputTokens", label: "Show output tokens", section: "Working Loader Text", group: "features", key: "outputTokens" },
127
+ { id: "animatedSpinner", label: "Animated spinner", section: "“Working” Loader", group: "decorations", key: "animatedSpinner" },
128
+ { id: "spinnerColor", label: "Animated spinner color", section: "“Working” Loader", group: "decorations", key: "spinnerColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "spinnerColorEnabled", cycleDisabledValue: "accent" },
129
+ { id: "substituteDefaultMessage", label: "Randomize “Working” text", section: "“Working” Loader", group: "features", key: "substituteDefaultMessage" },
130
+ { id: "shimmer", label: "Text shimmer", section: "“Working” Loader", group: "decorations", key: "shimmer" },
131
+ { id: "shimmerDirection", label: "Text shimmer direction", section: "“Working” Loader", group: "decorations", key: "shimmerDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "shimmerDirectionEnabled", cycleDisabledValue: "Left to Right" },
132
+ { id: "shimmerSpeed", label: "Text shimmer speed", section: "“Working” Loader", group: "decorations", key: "shimmerSpeed", cycleValues: ["Slow", "Normal", "Fast"], cycleEnabledBy: "shimmerSpeedEnabled", cycleDisabledValue: "Normal" },
133
+ { id: "tokenActivityMonitor", label: "Token activity monitor", section: "“Working” Loader", group: "decorations", key: "tokenActivityMonitor" },
134
+ { id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: ["accent", "border", "borderAccent"], cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
135
+ { id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["Left to Right", "Right to Left"], cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "Left to Right" },
136
+ { id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
137
+ { id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
138
+ { id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" },
95
139
  { id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
96
140
  { id: "doneMarkerIcon", label: "Pi icon", section: "Completion Marker", group: "features", key: "doneMarkerIcon" },
97
141
  { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
98
142
  { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
143
+ { id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
99
144
  { id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
100
145
  ];
101
146
 
@@ -119,18 +164,34 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
119
164
  case "meterColor":
120
165
  if (stored === "accent" || stored === "border" || stored === "borderAccent") decorations[key] = stored;
121
166
  return;
167
+ case "borderStyle":
168
+ if (stored === "double" || stored === "single" || stored === "rounded" || stored === "heavy") decorations[key] = stored;
169
+ return;
122
170
  case "shimmerDirection":
123
171
  case "meterDirection":
124
172
  if (stored === "ltr" || stored === "rtl") decorations[key] = stored;
125
173
  return;
174
+ case "shimmerSpeed":
175
+ if (stored === "slow" || stored === "normal" || stored === "fast") decorations[key] = stored;
176
+ return;
126
177
  default:
127
178
  // Fail loudly if a MENU_ENTRIES cycle entry is added without a handler here.
128
179
  throw new Error(`Unhandled cycle setting: ${String(key)}`);
129
180
  }
130
181
  }
131
182
 
183
+ function toggleSection(title: MenuSectionName, settings: DecoratorSettings): MenuSection {
184
+ return { title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) };
185
+ }
186
+
132
187
  export function buildMenuSections(settings: DecoratorSettings): MenuSection[] {
133
- return ["User Prompt", "Working Loader Text", "Completion Marker", "Options"].map(title => ({ title, items: MENU_ENTRIES.filter(entry => entry.section === title).map(entry => menuItem(entry, settings)) }));
188
+ return [
189
+ toggleSection("User Prompt", settings),
190
+ toggleSection("“Working” Loader", settings),
191
+ { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
192
+ toggleSection("Completion Marker", settings),
193
+ toggleSection("Options", settings),
194
+ ];
134
195
  }
135
196
 
136
197
  export function applyMenuResult(settings: DecoratorSettings, values: Record<string, boolean | string>): DecoratorSettings {
@@ -147,6 +208,7 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
147
208
  next.features[entry.key] = value;
148
209
  }
149
210
  }
211
+ if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]);
150
212
  return next;
151
213
  }
152
214
 
@@ -154,13 +216,15 @@ export function loadSettings(): DecoratorSettings {
154
216
  try {
155
217
  const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
156
218
  if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
157
- const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features) };
158
- const gated: readonly (readonly ["borderColor" | "spinnerColor" | "meterColor" | "shimmerDirection" | "meterDirection", "borderColorEnabled" | "spinnerColorEnabled" | "meterColorEnabled" | "shimmerDirectionEnabled" | "meterDirectionEnabled", string])[] = [
219
+ const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder) };
220
+ const gated: readonly (readonly ["borderColor" | "borderStyle" | "spinnerColor" | "meterColor" | "shimmerDirection" | "meterDirection" | "shimmerSpeed", "borderColorEnabled" | "borderStyleEnabled" | "spinnerColorEnabled" | "meterColorEnabled" | "shimmerDirectionEnabled" | "meterDirectionEnabled" | "shimmerSpeedEnabled", string])[] = [
159
221
  ["borderColor", "borderColorEnabled", "border"],
222
+ ["borderStyle", "borderStyleEnabled", "double"],
160
223
  ["spinnerColor", "spinnerColorEnabled", "accent"],
161
224
  ["meterColor", "meterColorEnabled", "accent"],
162
225
  ["shimmerDirection", "shimmerDirectionEnabled", "ltr"],
163
226
  ["meterDirection", "meterDirectionEnabled", "ltr"],
227
+ ["shimmerSpeed", "shimmerSpeedEnabled", "normal"],
164
228
  ];
165
229
  for (const [key, enabledKey, resetValue] of gated) {
166
230
  if (!settings.decorations[enabledKey]) (settings.decorations as unknown as Record<string, string>)[key] = resetValue;