@underactive/pi-topping 0.2.1 → 0.3.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
@@ -1,55 +1,61 @@
1
1
  # pi-topping
2
2
 
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 changes the default "Working..." loader with a shimmering activity word, a scrolling token-activity meter, an elapsed timer, and a live output-token count.
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
- ![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)
5
+ **User Prompt** — high-vis bordered prompt box with configurable border color, pi icon toggle, and timestamp.
6
+
7
+ ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/demo_user_prompt.png)
6
8
 
7
- ## What it does
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.
8
10
 
9
- - Picks a random activity word (`Cerebrating…`, `Noodling…`, `Zigzagging…`, etc.) when you submit a prompt, and a new one each time a tool call starts.
10
- - Sweeps a theme-aware shimmer across the word while it's showing.
11
- - Shows an elapsed timer (`Xm YYs`) that counts up from when you submitted the prompt.
12
- - Shows a live output-token estimate (`↓ N tokens`), reconciled against the real usage total once each assistant message finishes.
13
- - Resets everything — word, timer, token count — on each new prompt, and restores Pi's normal loader between turns.
14
- - Leaves a durable completion marker in the transcript after each finished turn, e.g. `π Baked for 6m 41s` — a fresh random word and the total time for that turn, rendered with `π` in the theme's primary text color and the rest dimmed. It's TUI-only display content and never enters LLM context.
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)
15
12
 
16
- ## Activity meter
13
+ **Completion Marker** — end-of-turn marker with icon, randomized verb, and token consumption display. Hooks into Pi's `agent_settled` event.
17
14
 
18
- Next to Pi's spinner there's an eight-column scrolling meter that reflects how fast tokens are streaming in. It updates every 100ms and uses your theme's accent color, so it should blend in with whatever theme you're running.
15
+ ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/demo_completion_marker.png)
19
16
 
20
17
  ## Settings
21
18
 
22
- Run `/topping-settings` (TUI only) to toggle things on or off:
19
+ Run `/topping-settings` (TUI only) to customize your toppings:
23
20
 
24
21
  ```
25
- ╔═[ Pi Topping: Settings ]══════════════════════════╗
26
- ╟─ Preview ─────────────────────────────────────────╢
27
- ║ ║
28
- ║ ⠋ Crafting ⣤⣤⣤⣤ (0m 03s · ↓ 84 tokens) ║
29
- ║ ║
30
- ╟─ Decorations ─────────────────────────────────────╢
31
- ║ ▸ [■] Animated spinner ON ║
32
- ║ [■] "Working..." text shimmer ON ║
33
- ║ [■] Token activity monitor ON ║
34
- ╟─ Options ─────────────────────────────────────────╢
35
- ║ [■] Substitute Pi's "Working..." message ON ║
36
- ║ [■] Elapsed time since prompt ON ║
37
- ║ [■] Show output tokens ON ║
38
- ║ [■] Show completion marker ON ║
39
- ║ [ ] Scrolling: Right → Left OFF ║
40
- ╟───────────────────────────────────────────────────╢
41
- ║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
42
- ╚═══════════════════════════════════════════════════╝
22
+ ╔═┥ Pi Topping: Settings ┝═══════════════════════════════════╗
23
+ ╟─ Preview ──────────────────────────────────────────────────╢
24
+ ║ ║
25
+ ║ Crafting (0m 03s · ↓ 84 tokens) ║
26
+ ║ ║
27
+ ╟─ User Prompt ──────────────────────────────────────────────╢
28
+ ║ [■] High-vis prompt ON ║
29
+ ║ [■] Border color ‹ accent › ║
30
+ ║ [■] Pi icon ON ║
31
+ ║ [■] Timestamp ON ║
32
+ ║ ║
33
+ ╟─ Working Loader Text ──────────────────────────────────────╢
34
+ ║ [■] Animated spinner ON ║
35
+ ║ ▸ [■] Animated spinner color ‹ accent › ║
36
+ ║ [■] Randomize "Working" text ON ║
37
+ ║ [■] Text shimmer ON ║
38
+ ║ [■] Text shimmer direction ‹ Left to Right › ║
39
+ ║ [■] Token activity monitor ON ║
40
+ ║ [■] Token activity monitor color ‹ accent › ║
41
+ ║ [■] Token activity monitor direction ‹ Left to Right › ║
42
+ ║ [■] Token activity monitor dimmed OFF ║
43
+ ║ [■] Elapsed time since prompt ON ║
44
+ ║ [■] Show output tokens ON ║
45
+ ║ ║
46
+ ╟─ Completion Marker ────────────────────────────────────────╢
47
+ ║ [■] Show completion marker ON ║
48
+ ║ [■] Pi icon ON ║
49
+ ║ [■] Randomize "Worked" text ON ║
50
+ ║ [■] Tokens spent ON ║
51
+ ║ ║
52
+ ╟─ Options ──────────────────────────────────────────────────╢
53
+ ║ [■] Use NerdFont icons ON ║
54
+ ╟────────────────────────────────────────────────────────────╢
55
+ ║ ↑↓ move ←→ select ␣ toggle ⏎ apply esc cancel ║
56
+ ╚═════════════════════════════════════════════════════┥ 1/9 ┝╝
43
57
  ```
44
58
 
45
- **Decorations** — animated spinner, word shimmer, activity meter.
46
-
47
- **Options** — whether to substitute the random word at all, the elapsed timer, the token count, the completion marker, and the meter scrolling direction.
48
-
49
- The menu has a live preview so you can see changes as you make them. Everything is on by default, so nothing changes unless you open the menu. Use `↑↓` to move, `␣` to toggle, `⏎` to save, `esc` to cancel. Settings are saved to `~/.pi/agent/pi-topping/settings.json`.
50
-
51
- The toggle-menu component itself lives in `menu.ts` and is reusable by other extensions if you want a similar settings UI.
52
-
53
59
  ## Install
54
60
 
55
61
  ```bash
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.2.1",
4
- "description": "A Pi extension that replaces the default \"Working...\" loader with a shimmering, randomized activity word, live elapsed timer, and output-token activity meter",
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",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
7
7
  "author": "Eric Sison",
@@ -117,12 +117,19 @@ export class ActivityMeter {
117
117
  this.#levels.fill(0);
118
118
  }
119
119
 
120
- /** Colorize a meter cell using the default theme mapping (dim at IDLE, accent otherwise). */
120
+ /** Colorize a meter cell using the default theme mapping (dim at IDLE, `color` otherwise). */
121
121
  static colorizeCell(
122
122
  level: ActivityMeterLevel,
123
123
  char: string,
124
124
  theme: { fg: (style: ThemeColor, s: string) => string },
125
+ color: ThemeColor = "accent",
126
+ dimmed?: boolean,
125
127
  ): string {
126
- return level === ActivityMeterLevel.IDLE ? theme.fg("dim", char) : theme.fg("accent", char);
128
+ if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
129
+ const colored = theme.fg(color, char);
130
+ // ANSI dim (SGR 2) reduces color brightness; SGR 22 resets dim/bold.
131
+ // This is necessary because theme.fg("dim", ...) sets a gray color instead
132
+ // of applying the ANSI dim attribute, so nesting would overwrite the accent color.
133
+ return dimmed ? `\x1b[2m${colored}\x1b[22m` : colored;
127
134
  }
128
135
  }
package/src/format.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ /** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
4
+ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
+
3
6
  /** Format an output-token count for the working indicator. */
4
7
  export function formatTokens(count: number): string {
5
8
  if (count < 1000) return count.toString();
@@ -68,6 +71,7 @@ export function shimmerString(
68
71
  text: string,
69
72
  elapsedMs: number,
70
73
  theme: Pick<Theme, "getFgAnsi">,
74
+ direction: "ltr" | "rtl" = "ltr",
71
75
  ): string {
72
76
  const chars = [...text];
73
77
  if (chars.length === 0) return "";
@@ -78,7 +82,8 @@ export function shimmerString(
78
82
  const SHIMMER_HIGHLIGHT = ansiToRgb(theme.getFgAnsi("text"));
79
83
  const period = chars.length + SHIMMER_PADDING * 2;
80
84
  const elapsedS = elapsedMs / 1000;
81
- const pos = ((elapsedS % SHIMMER_SWEEP_S) / SHIMMER_SWEEP_S) * period;
85
+ const sweep = ((elapsedS % SHIMMER_SWEEP_S) / SHIMMER_SWEEP_S) * period;
86
+ const pos = direction === "rtl" ? period - sweep : sweep;
82
87
 
83
88
  return chars
84
89
  .map((ch, i) => {
package/src/menu.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * contract and `ctx.ui.custom()` overlay API.
7
7
  *
8
8
  * Renders a titled box containing one or more sections of boolean toggle
9
- * items, plus an optional live-updating preview section driven by a
9
+ * or multi-value cycle items, plus an optional live-updating preview section driven by a
10
10
  * caller-supplied render callback, e.g.:
11
11
  *
12
12
  * ╔═[ Pi Topping: Settings ]════════════════════════╗
@@ -29,10 +29,26 @@
29
29
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
30
30
  import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
31
31
 
32
+ export type MenuValue = boolean | string;
33
+
34
+ /** Lines to display and an optional delay before the preview should refresh. */
35
+ export interface PreviewResult {
36
+ lines: string[];
37
+ nextRefreshInMs?: number;
38
+ }
39
+
32
40
  export interface MenuItem {
33
41
  id: string;
34
42
  label: string;
35
- value: boolean;
43
+ value: MenuValue;
44
+ /** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
45
+ cycleValues?: readonly string[];
46
+ /** ID of the boolean value that gates cycling; space toggles it. */
47
+ cycleEnabledBy?: string;
48
+ /** Initial enabled state when `cycleEnabledBy` is set (default true). */
49
+ cycleEnabled?: boolean;
50
+ /** Value snapped to when the gating checkbox is unchecked. */
51
+ cycleDisabledValue?: string;
36
52
  }
37
53
 
38
54
  export interface MenuSection {
@@ -45,20 +61,18 @@ export interface MenuConfig {
45
61
  sections: MenuSection[];
46
62
  hints?: string[];
47
63
  /**
48
- * Optional live preview renderer, shown in its own "Preview" section above
49
- * the toggle sections. Called on every render with the menu's current
50
- * (possibly toggled but not-yet-applied) values and the number of
51
- * milliseconds elapsed since the menu opened, so callers can drive
52
- * animation (e.g. a shimmer sweep or a scrolling meter). Return one string
53
- * per preview line; lines may contain ANSI styling and are truncated/
64
+ * Optional preview renderer, shown in its own "Preview" section above the
65
+ * toggle sections. Called on every render with the menu's current (possibly
66
+ * toggled but not-yet-applied) values and the number of milliseconds elapsed
67
+ * since the menu opened. Lines may contain ANSI styling and are truncated/
54
68
  * padded to fit automatically.
55
69
  *
56
- * When set, the menu re-renders itself on an interval (see
57
- * `previewIntervalMs`) so the preview animates even without keyboard
58
- * input.
70
+ * Return `string[]` for legacy interval-based animation (see
71
+ * `previewIntervalMs`), or a `PreviewResult` to declare the next refresh.
72
+ * Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
59
73
  */
60
- preview?: (values: Record<string, boolean>, elapsedMs: number) => string[];
61
- /** Interval in ms between preview re-renders when `preview` is set. Default 50. */
74
+ preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined) => string[] | PreviewResult;
75
+ /** Fallback delay in ms for legacy `string[]` previews. Default 50. */
62
76
  previewIntervalMs?: number;
63
77
  }
64
78
 
@@ -68,35 +82,56 @@ export interface MenuResult<T> {
68
82
  }
69
83
 
70
84
  const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
71
- const MIN_WIDTH = 36;
72
85
  const MAX_WIDTH = 76;
73
86
 
74
87
  interface FlatItem {
75
88
  id: string;
76
89
  label: string;
90
+ cycleValues?: readonly string[];
91
+ cycleEnabledBy?: string;
92
+ cycleDisabledValue?: string;
93
+ item: MenuItem;
94
+ sectionIndex: number;
95
+ }
96
+
97
+ function buildInitialValues(config: MenuConfig): Record<string, MenuValue> {
98
+ const values: Record<string, MenuValue> = {};
99
+ for (const section of config.sections) {
100
+ for (const item of section.items) {
101
+ values[item.id] = item.value;
102
+ if (item.cycleEnabledBy) values[item.cycleEnabledBy] = item.cycleEnabled ?? true;
103
+ }
104
+ }
105
+ return values;
77
106
  }
78
107
 
79
108
  /** Internal Component implementing the box-drawing toggle menu. */
80
109
  export class MenuComponent implements Component {
81
110
  private readonly theme: Theme;
82
- private readonly done: (result: MenuResult<Record<string, boolean>>) => void;
111
+ private readonly done: (result: MenuResult<Record<string, MenuValue>>) => void;
83
112
  private readonly title: string;
84
113
  private readonly sections: MenuSection[];
85
114
  private readonly hints: string[];
86
- private readonly initialValues: Record<string, boolean>;
87
- private readonly values: Record<string, boolean>;
115
+ private readonly initialValues: Record<string, MenuValue>;
116
+ private readonly values: Record<string, MenuValue>;
88
117
  private readonly flat: FlatItem[];
89
118
  private readonly previewFn: MenuConfig["preview"];
119
+ private readonly previewIntervalMs: number | undefined;
90
120
  private readonly previewOrigin: number | undefined;
91
- private previewTimer: ReturnType<typeof setInterval> | undefined;
121
+ private readonly tui: TUI | undefined;
122
+ private previewTimer: ReturnType<typeof setTimeout> | undefined;
123
+ private previewNextRefreshMs: number | undefined;
124
+ private disposed = false;
92
125
  private cursor = 0;
126
+ private scrollStart = 0;
93
127
  private cachedWidth: number | undefined;
128
+ private cachedRows: number | undefined;
94
129
  private cachedLines: string[] | undefined;
95
130
 
96
131
  constructor(
97
132
  config: MenuConfig,
98
133
  theme: Theme,
99
- done: (result: MenuResult<Record<string, boolean>>) => void,
134
+ done: (result: MenuResult<Record<string, MenuValue>>) => void,
100
135
  tui?: TUI,
101
136
  ) {
102
137
  this.theme = theme;
@@ -104,33 +139,32 @@ export class MenuComponent implements Component {
104
139
  this.title = config.title;
105
140
  this.sections = config.sections;
106
141
  this.hints = config.hints ?? DEFAULT_HINTS;
107
- this.values = {};
142
+ this.tui = tui;
143
+ this.values = buildInitialValues(config);
108
144
  this.flat = [];
109
- for (const section of config.sections) {
145
+ for (const [sectionIndex, section] of config.sections.entries()) {
110
146
  for (const item of section.items) {
111
- this.values[item.id] = item.value;
112
- this.flat.push({ id: item.id, label: item.label });
147
+ this.flat.push({ id: item.id, label: item.label, cycleValues: item.cycleValues, cycleEnabledBy: item.cycleEnabledBy, cycleDisabledValue: item.cycleDisabledValue, item, sectionIndex });
113
148
  }
114
149
  }
115
150
  this.initialValues = { ...this.values };
116
151
 
117
152
  this.previewFn = config.preview;
153
+ this.previewIntervalMs = config.previewIntervalMs;
118
154
  if (this.previewFn) {
119
155
  this.previewOrigin = Date.now();
120
156
  if (tui) {
121
- const intervalMs = config.previewIntervalMs ?? 50;
122
- this.previewTimer = setInterval(() => {
123
- this.invalidate();
124
- tui.requestRender();
125
- }, intervalMs);
157
+ this.samplePreview();
158
+ this.schedulePreview();
126
159
  }
127
160
  }
128
161
  }
129
162
 
130
163
  /** Stops the preview animation timer, if any. Called automatically when the overlay closes. */
131
164
  dispose(): void {
132
- if (this.previewTimer) {
133
- clearInterval(this.previewTimer);
165
+ this.disposed = true;
166
+ if (this.previewTimer !== undefined) {
167
+ clearTimeout(this.previewTimer);
134
168
  this.previewTimer = undefined;
135
169
  }
136
170
  }
@@ -147,7 +181,7 @@ export class MenuComponent implements Component {
147
181
 
148
182
  // Map input to a normalized key name.
149
183
  let mappedKey: string | undefined;
150
- for (const k of [Key.up, Key.down, Key.space, Key.enter, Key.escape]) {
184
+ for (const k of [Key.up, Key.down, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
151
185
  if (matchesKey(data, k)) {
152
186
  mappedKey = k;
153
187
  break;
@@ -164,9 +198,14 @@ export class MenuComponent implements Component {
164
198
  this.cursor = (this.cursor + 1) % this.flat.length;
165
199
  this.invalidate();
166
200
  },
201
+ [Key.left]: () => this.cycleCurrentValue(-1),
202
+ [Key.right]: () => this.cycleCurrentValue(1),
167
203
  [Key.space]: () => {
168
204
  const item = this.flat[this.cursor]!;
169
- this.values[item.id] = !this.values[item.id];
205
+ if (item.cycleValues && item.cycleEnabledBy) {
206
+ this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
207
+ if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
208
+ } else if (!item.cycleValues) this.values[item.id] = !this.values[item.id] as boolean;
170
209
  this.invalidate();
171
210
  },
172
211
  [Key.enter]: () => this.done({ applied: true, values: { ...this.values } }),
@@ -179,37 +218,60 @@ export class MenuComponent implements Component {
179
218
 
180
219
  invalidate(): void {
181
220
  this.cachedWidth = undefined;
221
+ this.cachedRows = undefined;
182
222
  this.cachedLines = undefined;
183
223
  }
184
224
 
185
225
  render(width: number): string[] {
186
- if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
187
- const lines = this.buildLines(width);
226
+ const rows = this.availableRows();
227
+ if (this.cachedWidth === width && this.cachedRows === rows && this.cachedLines) return this.cachedLines;
228
+ const lines = this.buildLines(width, rows);
188
229
  this.cachedWidth = width;
230
+ this.cachedRows = rows;
189
231
  this.cachedLines = lines;
232
+ this.schedulePreview();
190
233
  return lines;
191
234
  }
192
235
 
193
- private preferredWidth(previewLines: string[] | undefined): number {
194
- const candidates: number[] = [MIN_WIDTH - 2, 5 + this.title.length];
195
- if (previewLines && previewLines.length > 0) {
196
- candidates.push(3 + "Preview".length);
197
- // +1 for the leading space rendered in front of each preview line.
198
- for (const line of previewLines) candidates.push(visibleWidth(line) + 1);
199
- }
200
- for (const section of this.sections) {
201
- candidates.push(3 + section.title.length);
202
- for (const item of section.items) {
203
- // 8 fixed left columns + label + 2-space default gap + "OFF" (3) + 2 trailing.
204
- candidates.push(8 + item.label.length + 2 + 3 + 2);
205
- }
206
- }
207
- candidates.push(2 + this.hints.join(" ").length);
208
- return 2 + Math.max(...candidates);
236
+ private cycleCurrentValue(delta: number): void {
237
+ const item = this.flat[this.cursor]!;
238
+ if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
239
+ const current = item.cycleValues.indexOf(this.values[item.id] as string);
240
+ const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
241
+ this.values[item.id] = item.cycleValues[index]!;
242
+ this.invalidate();
209
243
  }
210
244
 
211
245
  private samplePreview(): string[] | undefined {
212
- return this.previewFn ? this.previewFn(this.values, this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0) : undefined;
246
+ if (!this.previewFn) return undefined;
247
+
248
+ const result = this.previewFn(
249
+ this.values,
250
+ this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
251
+ this.flat[this.cursor]?.id,
252
+ );
253
+ if (Array.isArray(result)) {
254
+ this.previewNextRefreshMs = this.previewIntervalMs ?? 50;
255
+ return result;
256
+ }
257
+
258
+ this.previewNextRefreshMs = result.nextRefreshInMs;
259
+ return result.lines;
260
+ }
261
+
262
+ private schedulePreview(): void {
263
+ if (this.previewTimer !== undefined) {
264
+ clearTimeout(this.previewTimer);
265
+ this.previewTimer = undefined;
266
+ }
267
+
268
+ const nextRefreshInMs = this.previewNextRefreshMs;
269
+ if (this.disposed || !this.tui || nextRefreshInMs === undefined || nextRefreshInMs <= 0) return;
270
+
271
+ this.previewTimer = setTimeout(() => {
272
+ this.invalidate();
273
+ this.tui?.requestRender();
274
+ }, nextRefreshInMs);
213
275
  }
214
276
 
215
277
  private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
@@ -226,20 +288,89 @@ export class MenuComponent implements Component {
226
288
  ]);
227
289
  }
228
290
 
291
+ /** Build a section-aware item window, keeping a divider above each visible section. */
292
+ private buildToggleWindow(innerWidth: number, start: number, maxRows: number): { lines: string[]; end: number } {
293
+ const lines: string[] = [];
294
+ let currentSection = -1;
295
+ let end = start - 1;
296
+ for (let index = start; index < this.flat.length && lines.length < maxRows; index++) {
297
+ const flat = this.flat[index]!;
298
+ if (flat.sectionIndex !== currentSection) {
299
+ const remaining = maxRows - lines.length;
300
+ if (lines.length > 0 && remaining >= 3) lines.push(this.renderBlankRow(innerWidth));
301
+ if (maxRows - lines.length >= 2) lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
302
+ currentSection = flat.sectionIndex;
303
+ }
304
+ if (lines.length >= maxRows) break;
305
+ lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
306
+ end = index;
307
+ }
308
+ if (end === this.flat.length - 1 && lines.length < maxRows) lines.push(this.renderBlankRow(innerWidth));
309
+ return { lines, end };
310
+ }
311
+
312
+ /** Render the scrolling settings body while keeping the selected item in view. */
313
+ private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
314
+ const allLines = this.buildToggleSections(innerWidth);
315
+ if (allLines.length <= maxRows) {
316
+ this.scrollStart = 0;
317
+ return allLines;
318
+ }
319
+ if (maxRows <= 0) return [];
320
+ if (maxRows === 1) return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
321
+
322
+ const contentRows = maxRows - 1; // Reserve one fixed row for scroll status.
323
+ if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
324
+ this.scrollStart = Math.max(0, Math.min(this.scrollStart, this.flat.length - 1));
325
+
326
+ let window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
327
+ while (window.end < this.cursor && this.scrollStart < this.cursor) {
328
+ this.scrollStart++;
329
+ window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
330
+ }
331
+ // Backfill from above whenever more context fits without hiding the cursor.
332
+ while (this.scrollStart > 0) {
333
+ const candidate = this.buildToggleWindow(innerWidth, this.scrollStart - 1, contentRows);
334
+ if (candidate.end < this.cursor) break;
335
+ this.scrollStart--;
336
+ window = candidate;
337
+ }
338
+
339
+ const rows = [...window.lines];
340
+ while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
341
+ rows.push(this.renderScrollStatus(this.scrollStart > 0, window.end < this.flat.length - 1, innerWidth));
342
+ return rows;
343
+ }
344
+
229
345
  private buildFooter(innerWidth: number): string[] {
230
346
  return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)];
231
347
  }
232
348
 
233
- private buildLines(maxWidth: number): string[] {
349
+ private availableRows(): number | undefined {
350
+ const rows = (this.tui as (TUI & { terminal?: { rows?: number } }) | undefined)?.terminal?.rows;
351
+ return typeof rows === "number" && Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : undefined;
352
+ }
353
+
354
+ private buildLines(maxWidth: number, maxRows?: number): string[] {
234
355
  const previewLines = this.samplePreview();
235
- const boxWidth = Math.max(0, Math.min(Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, this.preferredWidth(previewLines))), maxWidth));
356
+ const boxWidth = Math.max(0, Math.min(MAX_WIDTH, maxWidth));
236
357
  const innerWidth = Math.max(0, boxWidth - 2);
237
- return [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth), ...this.buildToggleSections(innerWidth), ...this.buildFooter(innerWidth)].map(line => truncateToWidth(line, boxWidth, ""));
358
+ const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
359
+ const footer = this.buildFooter(innerWidth);
360
+ const naturalBody = this.buildToggleSections(innerWidth);
361
+ const naturalHeight = header.length + naturalBody.length + footer.length;
362
+ if (maxRows === undefined || naturalHeight <= maxRows) {
363
+ return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
364
+ }
365
+
366
+ const bodyRows = Math.max(0, maxRows - header.length - footer.length);
367
+ const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
368
+ return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
238
369
  }
239
370
 
240
371
  private wrap(left: string, content: string, right: string): string {
241
372
  const th = this.theme;
242
- return th.fg("accent", left) + content + th.fg("accent", right);
373
+ return th.fg("border", left) + content + th.fg("border", right);
243
374
  }
244
375
 
245
376
  /** Render a filled horizontal line with optional title. */
@@ -255,7 +386,7 @@ export class MenuComponent implements Component {
255
386
  ): string {
256
387
  const th = this.theme;
257
388
  if (!title) {
258
- const content = th.fg("accent", fillChar.repeat(innerWidth));
389
+ const content = th.fg("border", fillChar.repeat(innerWidth));
259
390
  return this.wrap(left, content, right);
260
391
  }
261
392
  const prefix = titlePrefix ?? "";
@@ -264,7 +395,8 @@ export class MenuComponent implements Component {
264
395
  const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
265
396
  const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
266
397
  const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
267
- const content = th.fg("accent", `${prefix}${styledTitle}${suffix}${fillChar.repeat(fillCount)}`);
398
+ const titleContent = th.fg("text", styledTitle);
399
+ const content = `${th.fg("border", prefix)}${titleContent}${th.fg("border", suffix + fillChar.repeat(fillCount))}`;
268
400
  return this.wrap(left, content, right);
269
401
  }
270
402
 
@@ -276,7 +408,7 @@ export class MenuComponent implements Component {
276
408
  const counter = `[ ${this.cursor + 1}/${this.flat.length} ]`;
277
409
  const fillCount = Math.max(0, innerWidth - counter.length);
278
410
  const content = `${"\u2550".repeat(fillCount)}${counter}`;
279
- return this.wrap("\u255a", this.theme.fg("accent", content), "\u255d");
411
+ return this.wrap("\u255a", this.theme.fg("border", content), "\u255d");
280
412
  }
281
413
 
282
414
  private renderSectionDivider(title: string, innerWidth: number): string {
@@ -303,27 +435,39 @@ export class MenuComponent implements Component {
303
435
  return this.renderContentRow(this.theme.fg("dim", plain), innerWidth);
304
436
  }
305
437
 
438
+ private renderScrollStatus(hasAbove: boolean, hasBelow: boolean, innerWidth: number): string {
439
+ const parts = [hasAbove ? "↑ more above" : "", hasBelow ? "↓ more below" : ""].filter(Boolean);
440
+ return this.renderContentRow(this.theme.fg("dim", ` ${parts.join(" ")}`), innerWidth);
441
+ }
442
+
306
443
  private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
307
444
  const th = this.theme;
308
445
  const value = this.values[item.id]!;
309
446
  const marker = selected ? "\u25b8" : " ";
310
- const box = value ? "\u25a0" : " ";
311
- const stateWord = value ? "ON" : "OFF";
447
+ const markerColored = selected ? th.fg("accent", marker) : marker;
448
+
449
+ if (item.cycleValues) {
450
+ const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
451
+ const stateWord = `‹ ${value} ›`;
452
+ const fixedLeftLen = 8;
453
+ const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - visibleWidth(stateWord) - 1);
454
+ const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
455
+ const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
456
+ const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(stateWord) - 2);
457
+ const content = ` ${markerColored} [${enabled ? th.fg("success", "■") : th.fg("muted", " ")}] ${th.fg("text", label)}${" ".repeat(gap)}${enabled ? th.fg("accent", stateWord) : th.fg("muted", stateWord)} `;
458
+ return this.wrap("\u2551", content, "\u2551");
459
+ }
460
+
461
+ const enabled = value as boolean;
462
+ const box = enabled ? "\u25a0" : " ";
463
+ const stateWord = enabled ? "ON" : "OFF";
312
464
  const rightPlain = `${stateWord} `;
313
465
  const fixedLeftLen = 8; // " " + marker(1) + " " + "[" + box(1) + "]" + " "
314
- const minGap = 1;
315
- const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - minGap);
466
+ const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
316
467
  const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
317
-
318
468
  const leftPlain = ` ${marker} [${box}] ${label}`;
319
469
  const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
320
-
321
- const markerColored = selected ? th.fg("accent", marker) : marker;
322
- const boxColored = value ? th.fg("success", box) : th.fg("muted", box);
323
- const labelColored = th.fg("text", label);
324
- const stateColored = value ? th.fg("success", stateWord) : th.fg("muted", stateWord);
325
-
326
- const content = ` ${markerColored} [${boxColored}] ${labelColored}${" ".repeat(gap)}${stateColored} `;
470
+ 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)} `;
327
471
  return this.wrap("\u2551", content, "\u2551");
328
472
  }
329
473
  }
@@ -335,13 +479,11 @@ export class MenuComponent implements Component {
335
479
  * Requires TUI mode; in any other mode this resolves immediately with
336
480
  * `applied: false` and the menu's initial values, doing nothing visible.
337
481
  */
338
- export async function showMenu<T extends Record<string, boolean>>(
482
+ export async function showMenu<T extends Record<string, MenuValue>>(
339
483
  ctx: ExtensionCommandContext,
340
484
  config: MenuConfig,
341
485
  ): Promise<MenuResult<T>> {
342
- const initialValues = Object.fromEntries(
343
- config.sections.flatMap((section) => section.items.map((item) => [item.id, item.value])),
344
- ) as T;
486
+ const initialValues = buildInitialValues(config) as T;
345
487
 
346
488
  if (ctx.mode !== "tui") {
347
489
  return { applied: false, values: initialValues };
@@ -349,7 +491,7 @@ export async function showMenu<T extends Record<string, boolean>>(
349
491
 
350
492
  return ctx.ui.custom<MenuResult<T>>(
351
493
  (tui, theme, _keybindings, done) =>
352
- new MenuComponent(config, theme, done as (result: MenuResult<Record<string, boolean>>) => void, tui),
353
- { overlay: true },
494
+ new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
495
+ { overlay: true, overlayOptions: { width: MAX_WIDTH, maxHeight: "100%" } },
354
496
  );
355
497
  }