@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 +44 -38
- package/package.json +2 -2
- package/src/activity-meter.ts +9 -2
- package/src/format.ts +6 -1
- package/src/menu.ts +218 -76
- package/src/preview.ts +50 -36
- package/src/prompt-decorator.ts +77 -0
- package/src/session.ts +356 -26
- package/src/settings.ts +118 -159
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
|
|
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
|
-
|
|
5
|
+
**User Prompt** — high-vis bordered prompt box with configurable border color, pi icon toggle, and timestamp.
|
|
6
|
+
|
|
7
|
+

|
|
6
8
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
+

|
|
15
12
|
|
|
16
|
-
|
|
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
|
-
|
|
15
|
+

|
|
19
16
|
|
|
20
17
|
## Settings
|
|
21
18
|
|
|
22
|
-
Run `/topping-settings` (TUI only) to
|
|
19
|
+
Run `/topping-settings` (TUI only) to customize your toppings:
|
|
23
20
|
|
|
24
21
|
```
|
|
25
|
-
|
|
26
|
-
╟─ Preview
|
|
27
|
-
║
|
|
28
|
-
║
|
|
29
|
-
║
|
|
30
|
-
╟─
|
|
31
|
-
║
|
|
32
|
-
║ [■]
|
|
33
|
-
║ [■]
|
|
34
|
-
|
|
35
|
-
║
|
|
36
|
-
|
|
37
|
-
║ [■]
|
|
38
|
-
║
|
|
39
|
-
║ [
|
|
40
|
-
|
|
41
|
-
║
|
|
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.
|
|
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",
|
package/src/activity-meter.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
-
*
|
|
57
|
-
* `previewIntervalMs`)
|
|
58
|
-
*
|
|
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,
|
|
61
|
-
/**
|
|
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,
|
|
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,
|
|
87
|
-
private readonly values: Record<string,
|
|
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
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
122
|
-
this.
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
356
|
+
const boxWidth = Math.max(0, Math.min(MAX_WIDTH, maxWidth));
|
|
236
357
|
const innerWidth = Math.max(0, boxWidth - 2);
|
|
237
|
-
|
|
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("
|
|
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("
|
|
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
|
|
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("
|
|
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
|
|
311
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
}
|