@underactive/pi-topping 0.2.0 → 0.3.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 +44 -38
- package/index.ts +1 -1
- package/package.json +3 -3
- package/{activity-meter.ts → src/activity-meter.ts} +9 -2
- package/{format.ts → src/format.ts} +26 -11
- package/src/menu.ts +497 -0
- package/src/preview.ts +61 -0
- package/src/prompt-decorator.ts +77 -0
- package/src/session.ts +370 -0
- package/src/settings.ts +174 -0
- package/menu.ts +0 -355
- package/preview.ts +0 -47
- package/session.ts +0 -43
- package/settings.ts +0 -215
- /package/{words.ts → src/words.ts} +0 -0
package/src/menu.ts
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* menu.ts
|
|
3
|
+
*
|
|
4
|
+
* A reusable, dependency-light box-drawing toggle-menu component for Pi
|
|
5
|
+
* extensions, built on top of `@earendil-works/pi-tui`'s `Component`
|
|
6
|
+
* contract and `ctx.ui.custom()` overlay API.
|
|
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.:
|
|
11
|
+
*
|
|
12
|
+
* ╔═[ Pi Topping: Settings ]════════════════════════╗
|
|
13
|
+
* ╟─ Preview ─────────────────────────────────────╢
|
|
14
|
+
* ║ ║
|
|
15
|
+
* ║ ⠋ Cerebrating… ⣤⣤⣤⣤ (0m 03s · ↓ 84 tokens) ║
|
|
16
|
+
* ║ ║
|
|
17
|
+
* ╟─ Decorations ───────────────────────────────────╢
|
|
18
|
+
* ║ ▸ [■] Animated spinner ON ║
|
|
19
|
+
* ║ [ ] "Working..." text shimmer OFF ║
|
|
20
|
+
* ║ ║
|
|
21
|
+
* ╟──────────────────────────────────────────────────╢
|
|
22
|
+
* ║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
|
|
23
|
+
* ╚══════════════════════════════════════════[ 1/2 ]═╝
|
|
24
|
+
*
|
|
25
|
+
* Intended to be reused by any extension that needs a simple modal toggle
|
|
26
|
+
* menu; it has no dependency on this extension's own settings shape.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
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
|
+
|
|
40
|
+
export interface MenuItem {
|
|
41
|
+
id: string;
|
|
42
|
+
label: string;
|
|
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;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MenuSection {
|
|
55
|
+
title: string;
|
|
56
|
+
items: MenuItem[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface MenuConfig {
|
|
60
|
+
title: string;
|
|
61
|
+
sections: MenuSection[];
|
|
62
|
+
hints?: string[];
|
|
63
|
+
/**
|
|
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/
|
|
68
|
+
* padded to fit automatically.
|
|
69
|
+
*
|
|
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.
|
|
73
|
+
*/
|
|
74
|
+
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined) => string[] | PreviewResult;
|
|
75
|
+
/** Fallback delay in ms for legacy `string[]` previews. Default 50. */
|
|
76
|
+
previewIntervalMs?: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface MenuResult<T> {
|
|
80
|
+
applied: boolean;
|
|
81
|
+
values: T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
85
|
+
const MAX_WIDTH = 76;
|
|
86
|
+
|
|
87
|
+
interface FlatItem {
|
|
88
|
+
id: string;
|
|
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;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Internal Component implementing the box-drawing toggle menu. */
|
|
109
|
+
export class MenuComponent implements Component {
|
|
110
|
+
private readonly theme: Theme;
|
|
111
|
+
private readonly done: (result: MenuResult<Record<string, MenuValue>>) => void;
|
|
112
|
+
private readonly title: string;
|
|
113
|
+
private readonly sections: MenuSection[];
|
|
114
|
+
private readonly hints: string[];
|
|
115
|
+
private readonly initialValues: Record<string, MenuValue>;
|
|
116
|
+
private readonly values: Record<string, MenuValue>;
|
|
117
|
+
private readonly flat: FlatItem[];
|
|
118
|
+
private readonly previewFn: MenuConfig["preview"];
|
|
119
|
+
private readonly previewIntervalMs: number | undefined;
|
|
120
|
+
private readonly previewOrigin: number | undefined;
|
|
121
|
+
private readonly tui: TUI | undefined;
|
|
122
|
+
private previewTimer: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
private previewNextRefreshMs: number | undefined;
|
|
124
|
+
private disposed = false;
|
|
125
|
+
private cursor = 0;
|
|
126
|
+
private scrollStart = 0;
|
|
127
|
+
private cachedWidth: number | undefined;
|
|
128
|
+
private cachedRows: number | undefined;
|
|
129
|
+
private cachedLines: string[] | undefined;
|
|
130
|
+
|
|
131
|
+
constructor(
|
|
132
|
+
config: MenuConfig,
|
|
133
|
+
theme: Theme,
|
|
134
|
+
done: (result: MenuResult<Record<string, MenuValue>>) => void,
|
|
135
|
+
tui?: TUI,
|
|
136
|
+
) {
|
|
137
|
+
this.theme = theme;
|
|
138
|
+
this.done = done;
|
|
139
|
+
this.title = config.title;
|
|
140
|
+
this.sections = config.sections;
|
|
141
|
+
this.hints = config.hints ?? DEFAULT_HINTS;
|
|
142
|
+
this.tui = tui;
|
|
143
|
+
this.values = buildInitialValues(config);
|
|
144
|
+
this.flat = [];
|
|
145
|
+
for (const [sectionIndex, section] of config.sections.entries()) {
|
|
146
|
+
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 });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this.initialValues = { ...this.values };
|
|
151
|
+
|
|
152
|
+
this.previewFn = config.preview;
|
|
153
|
+
this.previewIntervalMs = config.previewIntervalMs;
|
|
154
|
+
if (this.previewFn) {
|
|
155
|
+
this.previewOrigin = Date.now();
|
|
156
|
+
if (tui) {
|
|
157
|
+
this.samplePreview();
|
|
158
|
+
this.schedulePreview();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Stops the preview animation timer, if any. Called automatically when the overlay closes. */
|
|
164
|
+
dispose(): void {
|
|
165
|
+
this.disposed = true;
|
|
166
|
+
if (this.previewTimer !== undefined) {
|
|
167
|
+
clearTimeout(this.previewTimer);
|
|
168
|
+
this.previewTimer = undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
handleInput(data: string): void {
|
|
173
|
+
if (this.flat.length === 0) {
|
|
174
|
+
if (matchesKey(data, Key.enter)) {
|
|
175
|
+
this.done({ applied: true, values: { ...this.values } });
|
|
176
|
+
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
177
|
+
this.done({ applied: false, values: { ...this.initialValues } });
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Map input to a normalized key name.
|
|
183
|
+
let mappedKey: string | undefined;
|
|
184
|
+
for (const k of [Key.up, Key.down, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
|
|
185
|
+
if (matchesKey(data, k)) {
|
|
186
|
+
mappedKey = k;
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!mappedKey && matchesKey(data, Key.ctrl("c"))) mappedKey = Key.escape;
|
|
191
|
+
|
|
192
|
+
const keyActions: Record<string, () => void> = {
|
|
193
|
+
[Key.up]: () => {
|
|
194
|
+
this.cursor = (this.cursor - 1 + this.flat.length) % this.flat.length;
|
|
195
|
+
this.invalidate();
|
|
196
|
+
},
|
|
197
|
+
[Key.down]: () => {
|
|
198
|
+
this.cursor = (this.cursor + 1) % this.flat.length;
|
|
199
|
+
this.invalidate();
|
|
200
|
+
},
|
|
201
|
+
[Key.left]: () => this.cycleCurrentValue(-1),
|
|
202
|
+
[Key.right]: () => this.cycleCurrentValue(1),
|
|
203
|
+
[Key.space]: () => {
|
|
204
|
+
const item = this.flat[this.cursor]!;
|
|
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;
|
|
209
|
+
this.invalidate();
|
|
210
|
+
},
|
|
211
|
+
[Key.enter]: () => this.done({ applied: true, values: { ...this.values } }),
|
|
212
|
+
[Key.escape]: () => this.done({ applied: false, values: { ...this.initialValues } }),
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const handler = mappedKey ? keyActions[mappedKey] : undefined;
|
|
216
|
+
if (handler) handler();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
invalidate(): void {
|
|
220
|
+
this.cachedWidth = undefined;
|
|
221
|
+
this.cachedRows = undefined;
|
|
222
|
+
this.cachedLines = undefined;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
render(width: number): string[] {
|
|
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);
|
|
229
|
+
this.cachedWidth = width;
|
|
230
|
+
this.cachedRows = rows;
|
|
231
|
+
this.cachedLines = lines;
|
|
232
|
+
this.schedulePreview();
|
|
233
|
+
return lines;
|
|
234
|
+
}
|
|
235
|
+
|
|
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();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private samplePreview(): string[] | 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);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private buildPreviewBlock(previewLines: string[] | undefined, innerWidth: number): string[] {
|
|
278
|
+
if (!previewLines?.length) return [];
|
|
279
|
+
return [this.renderSectionDivider("Preview", innerWidth), this.renderBlankRow(innerWidth), ...previewLines.map(line => this.renderContentRow(` ${line}`, innerWidth)), this.renderBlankRow(innerWidth)];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
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
|
+
]);
|
|
289
|
+
}
|
|
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
|
+
|
|
345
|
+
private buildFooter(innerWidth: number): string[] {
|
|
346
|
+
return [this.renderSeparator(innerWidth), this.renderHintsRow(innerWidth), this.renderBottomBorder(innerWidth)];
|
|
347
|
+
}
|
|
348
|
+
|
|
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[] {
|
|
355
|
+
const previewLines = this.samplePreview();
|
|
356
|
+
const boxWidth = Math.max(0, Math.min(MAX_WIDTH, maxWidth));
|
|
357
|
+
const innerWidth = Math.max(0, boxWidth - 2);
|
|
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, ""));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private wrap(left: string, content: string, right: string): string {
|
|
372
|
+
const th = this.theme;
|
|
373
|
+
return th.fg("border", left) + content + th.fg("border", right);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Render a filled horizontal line with optional title. */
|
|
377
|
+
private renderFilledLine(
|
|
378
|
+
left: string,
|
|
379
|
+
right: string,
|
|
380
|
+
fillChar: string,
|
|
381
|
+
innerWidth: number,
|
|
382
|
+
title?: string,
|
|
383
|
+
titlePrefix?: string,
|
|
384
|
+
titleSuffix?: string,
|
|
385
|
+
boldTitle?: boolean,
|
|
386
|
+
): string {
|
|
387
|
+
const th = this.theme;
|
|
388
|
+
if (!title) {
|
|
389
|
+
const content = th.fg("border", fillChar.repeat(innerWidth));
|
|
390
|
+
return this.wrap(left, content, right);
|
|
391
|
+
}
|
|
392
|
+
const prefix = titlePrefix ?? "";
|
|
393
|
+
const suffix = titleSuffix ?? "";
|
|
394
|
+
const maxTitleLen = Math.max(0, innerWidth - prefix.length - suffix.length);
|
|
395
|
+
const shownTitle = title.length > maxTitleLen ? truncateToWidth(title, maxTitleLen) : title;
|
|
396
|
+
const styledTitle = boldTitle ? th.bold(shownTitle) : shownTitle;
|
|
397
|
+
const fillCount = Math.max(0, innerWidth - visibleWidth(prefix + shownTitle + suffix));
|
|
398
|
+
const titleContent = th.fg("text", styledTitle);
|
|
399
|
+
const content = `${th.fg("border", prefix)}${titleContent}${th.fg("border", suffix + fillChar.repeat(fillCount))}`;
|
|
400
|
+
return this.wrap(left, content, right);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
private renderTopBorder(innerWidth: number): string {
|
|
404
|
+
return this.renderFilledLine("\u2554", "\u2557", "\u2550", innerWidth, this.title, "\u2550[ ", " ]", true);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private renderBottomBorder(innerWidth: number): string {
|
|
408
|
+
const counter = `[ ${this.cursor + 1}/${this.flat.length} ]`;
|
|
409
|
+
const fillCount = Math.max(0, innerWidth - counter.length);
|
|
410
|
+
const content = `${"\u2550".repeat(fillCount)}${counter}`;
|
|
411
|
+
return this.wrap("\u255a", this.theme.fg("border", content), "\u255d");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private renderSectionDivider(title: string, innerWidth: number): string {
|
|
415
|
+
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth, title, "\u2500 ", " \u2500");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private renderSeparator(innerWidth: number): string {
|
|
419
|
+
return this.renderFilledLine("\u255f", "\u2562", "\u2500", innerWidth);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private renderBlankRow(innerWidth: number): string {
|
|
423
|
+
return this.wrap("\u2551", " ".repeat(innerWidth), "\u2551");
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Pads/truncates an already-styled content string to exactly `innerWidth` and wraps it in border chars. */
|
|
427
|
+
private renderContentRow(content: string, innerWidth: number): string {
|
|
428
|
+
const shown = visibleWidth(content) > innerWidth ? truncateToWidth(content, innerWidth) : content;
|
|
429
|
+
const pad = Math.max(0, innerWidth - visibleWidth(shown));
|
|
430
|
+
return this.wrap("\u2551", `${shown}${" ".repeat(pad)}`, "\u2551");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private renderHintsRow(innerWidth: number): string {
|
|
434
|
+
const plain = ` ${this.hints.join(" ")}`;
|
|
435
|
+
return this.renderContentRow(this.theme.fg("dim", plain), innerWidth);
|
|
436
|
+
}
|
|
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
|
+
|
|
443
|
+
private renderItemRow(item: MenuItem, selected: boolean, innerWidth: number): string {
|
|
444
|
+
const th = this.theme;
|
|
445
|
+
const value = this.values[item.id]!;
|
|
446
|
+
const marker = selected ? "\u25b8" : " ";
|
|
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";
|
|
464
|
+
const rightPlain = `${stateWord} `;
|
|
465
|
+
const fixedLeftLen = 8; // " " + marker(1) + " " + "[" + box(1) + "]" + " "
|
|
466
|
+
const maxLabelLen = Math.max(0, innerWidth - fixedLeftLen - rightPlain.length - 1);
|
|
467
|
+
const label = item.label.length > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
468
|
+
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
469
|
+
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
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)} `;
|
|
471
|
+
return this.wrap("\u2551", content, "\u2551");
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Show a modal box-drawing toggle menu and resolve once the user applies
|
|
477
|
+
* (Enter) or cancels (Escape / Ctrl+C) it.
|
|
478
|
+
*
|
|
479
|
+
* Requires TUI mode; in any other mode this resolves immediately with
|
|
480
|
+
* `applied: false` and the menu's initial values, doing nothing visible.
|
|
481
|
+
*/
|
|
482
|
+
export async function showMenu<T extends Record<string, MenuValue>>(
|
|
483
|
+
ctx: ExtensionCommandContext,
|
|
484
|
+
config: MenuConfig,
|
|
485
|
+
): Promise<MenuResult<T>> {
|
|
486
|
+
const initialValues = buildInitialValues(config) as T;
|
|
487
|
+
|
|
488
|
+
if (ctx.mode !== "tui") {
|
|
489
|
+
return { applied: false, values: initialValues };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return ctx.ui.custom<MenuResult<T>>(
|
|
493
|
+
(tui, theme, _keybindings, done) =>
|
|
494
|
+
new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
|
|
495
|
+
{ overlay: true, overlayOptions: { width: MAX_WIDTH, maxHeight: "100%" } },
|
|
496
|
+
);
|
|
497
|
+
}
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PreviewResult } from "./menu.ts";
|
|
3
|
+
import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
|
|
4
|
+
import { buildWorkingMessage, formatElapsed, formatTokens, isFullyDefaultAppearance, shimmerString, SPINNER_FRAMES } from "./format.ts";
|
|
5
|
+
import { buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
6
|
+
import { pickRandomWord } from "./words.ts";
|
|
7
|
+
const METER_INTERVAL_MS = 100, METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
8
|
+
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"]);
|
|
11
|
+
function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.PI * elapsedMs) / METER_PERIOD_MS)) / 2) * METER_PEAK_RATE; }
|
|
12
|
+
|
|
13
|
+
/** Stateful, per-menu preview renderer that follows the active settings section. */
|
|
14
|
+
export class PreviewRenderer {
|
|
15
|
+
readonly #word = pickRandomWord(); readonly #meter = new ActivityMeter(); #lastMeterUpdate = 0;
|
|
16
|
+
readonly #ctx: ExtensionContext;
|
|
17
|
+
constructor(ctx: ExtensionContext) { this.#ctx = ctx; }
|
|
18
|
+
render(values: Record<string, boolean | string>, elapsedMs: number, activeItemId?: string): PreviewResult {
|
|
19
|
+
if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values);
|
|
20
|
+
if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values);
|
|
21
|
+
if (activeItemId === "useNerdFont") return { lines: [`Icon preview: ${values.useNerdFont ? "" : "π"}`] };
|
|
22
|
+
return { lines: ["", this.loaderPreview(values, elapsedMs), ""], nextRefreshInMs: 80 };
|
|
23
|
+
}
|
|
24
|
+
private loaderPreview(values: Record<string, boolean | string>, elapsedMs: number): string {
|
|
25
|
+
this.#meter.setDirection(values.meterDirection === "Right to Left" ? "rtl" : "ltr");
|
|
26
|
+
if (elapsedMs - this.#lastMeterUpdate >= METER_INTERVAL_MS) { this.#meter.push(rateToLevel(meterRate(elapsedMs))); this.#lastMeterUpdate = elapsedMs; }
|
|
27
|
+
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false };
|
|
28
|
+
const decorations = { shimmer: values.shimmer !== false, tokenActivityMonitor: values.tokenActivityMonitor !== false };
|
|
29
|
+
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(" ");
|
|
32
|
+
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);
|
|
34
|
+
const meterColor = (values.meterColorEnabled === false ? "accent" : values.meterColor === "border" || values.meterColor === "borderAccent" ? values.meterColor : "accent") as "accent" | "border" | "borderAccent";
|
|
35
|
+
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(" ");
|
|
37
|
+
}
|
|
38
|
+
private promptPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
39
|
+
if (!values.decorateUserPrompt) return { lines: ["User prompt decoration is disabled."] };
|
|
40
|
+
|
|
41
|
+
const borderColor = values.borderColor;
|
|
42
|
+
const timestamp = values.promptTimestamp ? Date.now() : undefined;
|
|
43
|
+
const lines = buildPromptBoxLines("ping", timestamp, 70, this.#ctx.ui.theme, {
|
|
44
|
+
showIcon: values.promptIcon as boolean,
|
|
45
|
+
showTimestamp: values.promptTimestamp as boolean,
|
|
46
|
+
icon: values.useNerdFont ? "" : "π",
|
|
47
|
+
borderColor: borderColor === "accent" || borderColor === "border" || borderColor === "borderAccent" ? borderColor : "accent",
|
|
48
|
+
});
|
|
49
|
+
return timestamp === undefined
|
|
50
|
+
? { lines }
|
|
51
|
+
: { lines, nextRefreshInMs: 1_000 - (timestamp % 1_000) };
|
|
52
|
+
}
|
|
53
|
+
private markerPreview(values: Record<string, boolean | string>): PreviewResult {
|
|
54
|
+
if (!values.doneMarker) return { lines: ["Completion marker is disabled."] };
|
|
55
|
+
const th = this.#ctx.ui.theme;
|
|
56
|
+
const icon = values.doneMarkerIcon ? `${values.useNerdFont ? "" : "π"} ` : "";
|
|
57
|
+
const word = (values.randomizeDoneMarker ? "Concocted" : "Worked") as string;
|
|
58
|
+
const tail = (values.doneMarkerTokens ? " (↓ 949 tokens)" : "") as string;
|
|
59
|
+
return { lines: ["", `${values.doneMarkerIcon ? th.fg("text", icon) : ""}${th.fg("dim", `${word} for 2m 52s${tail}`)}`, ""] };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
export const PROMPT_BOX_TYPE = "pi-topping-prompt";
|
|
5
|
+
|
|
6
|
+
export interface PromptBoxDetails {
|
|
7
|
+
submittedAt?: number;
|
|
8
|
+
showIcon?: boolean;
|
|
9
|
+
showTimestamp?: boolean;
|
|
10
|
+
icon?: string;
|
|
11
|
+
borderColor?: "accent" | "border" | "borderAccent";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type PromptTheme = { fg(color: string, text: string): string };
|
|
15
|
+
|
|
16
|
+
/** Build the rendered lines for a decorated user prompt. */
|
|
17
|
+
export function buildPromptBoxLines(
|
|
18
|
+
content: string,
|
|
19
|
+
submittedAt: number | undefined,
|
|
20
|
+
width: number,
|
|
21
|
+
theme: PromptTheme,
|
|
22
|
+
options: PromptBoxDetails = {},
|
|
23
|
+
): string[] {
|
|
24
|
+
if (width < 10) return [];
|
|
25
|
+
|
|
26
|
+
const border = (text: string) => theme.fg(options.borderColor ?? "accent", text);
|
|
27
|
+
const label = (text: string) => theme.fg("customMessageLabel", text);
|
|
28
|
+
const muted = (text: string) => theme.fg("dim", text);
|
|
29
|
+
const bodyText = (text: string) => theme.fg("customMessageText", text);
|
|
30
|
+
const time = options.showTimestamp === false || !submittedAt
|
|
31
|
+
? ""
|
|
32
|
+
: new Date(submittedAt).toLocaleTimeString("en-GB", {
|
|
33
|
+
hour12: false,
|
|
34
|
+
hour: "2-digit",
|
|
35
|
+
minute: "2-digit",
|
|
36
|
+
second: "2-digit",
|
|
37
|
+
});
|
|
38
|
+
const innerWidth = width - 2;
|
|
39
|
+
const icon = options.showIcon === false ? "" : (options.icon ?? "");
|
|
40
|
+
const title = icon ? `══ ${icon} ` : "";
|
|
41
|
+
const titleLength = visibleWidth(title);
|
|
42
|
+
const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
|
|
43
|
+
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("╗")}`;
|
|
45
|
+
const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
|
|
46
|
+
|
|
47
|
+
if (content) {
|
|
48
|
+
const textWidth = innerWidth - 2;
|
|
49
|
+
for (const rawLine of wrapTextWithAnsi(content, textWidth)) {
|
|
50
|
+
const displayLine = rawLine || " ";
|
|
51
|
+
const padded = `${displayLine}${" ".repeat(Math.max(0, textWidth - visibleWidth(displayLine)))}`;
|
|
52
|
+
const line = `${border("║")}${bodyText(` ${padded} `)}${border("║")}`;
|
|
53
|
+
lines.push(visibleWidth(line) > width ? truncateToWidth(line, width) : line);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const bottomLine = `${border("╚")}${border("═".repeat(innerWidth))}${border("╝")}`;
|
|
58
|
+
lines.push(visibleWidth(bottomLine) > width ? truncateToWidth(bottomLine, width) : bottomLine);
|
|
59
|
+
return lines;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
|
|
63
|
+
const content = typeof message.content === "string" ? message.content : "";
|
|
64
|
+
const details = (message.details ?? {}) as PromptBoxDetails;
|
|
65
|
+
const linesByWidth = new Map<number, string[]>();
|
|
66
|
+
return {
|
|
67
|
+
render(width: number): string[] {
|
|
68
|
+
let lines = linesByWidth.get(width);
|
|
69
|
+
if (!lines) {
|
|
70
|
+
lines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
|
|
71
|
+
linesByWidth.set(width, lines);
|
|
72
|
+
}
|
|
73
|
+
return lines;
|
|
74
|
+
},
|
|
75
|
+
invalidate() { linesByWidth.clear(); },
|
|
76
|
+
};
|
|
77
|
+
};
|