@underactive/pi-topping 0.6.5 → 0.7.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 +10 -6
- package/package.json +1 -1
- package/src/activity-meter.ts +3 -3
- package/src/format.ts +32 -12
- package/src/menu.ts +27 -39
- package/src/pi-installer.ts +15 -6
- package/src/preview.ts +18 -17
- package/src/prompt-decorator.ts +31 -17
- package/src/session.ts +50 -45
- package/src/settings.ts +79 -55
- package/src/setup-command.ts +94 -48
- package/src/toppings.ts +67 -18
- package/src/word-packs.ts +1 -1
- package/src/words.ts +2 -10
package/README.md
CHANGED
|
@@ -31,12 +31,12 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
31
31
|
╔═[ Pi Topping: Settings ]══════════════════════════════════════╗
|
|
32
32
|
╟─ Preview ─────────────────────────────────────────────────────╢
|
|
33
33
|
║ ║
|
|
34
|
-
║ ⠋ Crafting
|
|
34
|
+
║ ⠋ Crafting ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
|
|
35
35
|
║ ║
|
|
36
36
|
╟─ User Prompt ─────────────────────────────────────────────────╢
|
|
37
37
|
║ [■] High-vis prompt ON ║
|
|
38
38
|
║ [■] Border style ‹ double › ║
|
|
39
|
-
║ [■] Border color
|
|
39
|
+
║ [■] Border color ‹ thinkingLevel › ║
|
|
40
40
|
║ [■] Pi icon ON ║
|
|
41
41
|
║ [■] Timestamp ON ║
|
|
42
42
|
║ [■] Provider ON ║
|
|
@@ -44,7 +44,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
44
44
|
║ ║
|
|
45
45
|
╟─ “Working” Loader ────────────────────────────────────────────╢
|
|
46
46
|
║ [■] Animated spinner ON ║
|
|
47
|
-
║ ❯ [■] Animated spinner color
|
|
47
|
+
║ ❯ [■] Animated spinner color ‹ thinkingLevel › ║
|
|
48
48
|
║ [■] Randomize “Working” text ON ║
|
|
49
49
|
║ [■] Text shimmer ON ║
|
|
50
50
|
║ [■] Invert shimmer OFF ║
|
|
@@ -76,7 +76,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
76
76
|
║ [■] Show completion marker ON ║
|
|
77
77
|
║ [■] Marker style ‹ elite › ║
|
|
78
78
|
║ [■] Border style ‹ none › ║
|
|
79
|
-
║ [■] Border color
|
|
79
|
+
║ [■] Border color ‹ thinkingLevel › ║
|
|
80
80
|
║ [■] Pi icon ON ║
|
|
81
81
|
║ [■] Randomize “Worked” text ON ║
|
|
82
82
|
║ [■] Tokens spent ON ║
|
|
@@ -112,12 +112,15 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
|
|
|
112
112
|
"packs": [{
|
|
113
113
|
"id": "cooking",
|
|
114
114
|
"name": "Cooking",
|
|
115
|
+
"description": "Kitchen operations.",
|
|
115
116
|
"words": [{ "present_tense": "Sautéing onions", "past_tense": "Sautéed" }]
|
|
116
117
|
}]
|
|
117
118
|
}
|
|
118
119
|
```
|
|
119
120
|
|
|
120
|
-
Pack IDs must start with a lowercase letter
|
|
121
|
+
Pack IDs must start with a lowercase letter, contain only lowercase letters, digits, and `-`, and be at most 64 characters. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. An optional `description` appears in the settings preview when the pack row is selected, and an optional `attribution` records the source. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
|
|
122
|
+
|
|
123
|
+
Every color setting offers `thinking-level` (shown as `thinkingLevel` in `/topping-settings`); it is the default for the animated spinner, the prompt border, and the completion marker border. The spinner, token activity monitor, token rate, and response model follow Pi's current thinking level. The prompt border uses the level captured when the prompt was submitted, and the completion marker uses the level captured when the turn settled, so earlier entries keep their color after you change level. When hand-editing `settings.json`, write `thinking-level`; the legacy `default` spelling is only recognised for `spinnerColor` and `doneMarkerBorderColor`.
|
|
121
124
|
|
|
122
125
|
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
123
126
|
left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
|
|
@@ -136,6 +139,7 @@ The response model is captured from assistant responses, displayed as its saniti
|
|
|
136
139
|
## Requirements
|
|
137
140
|
|
|
138
141
|
- [Pi](https://github.com/earendil-works/pi-coding-agent). Pi ≥0.84.4 is required for the waiting-for-input loader state; older Pi versions keep the animated loader.
|
|
142
|
+
- On Pi ≥0.85.0, the working indicator is embedded in the default editor border and long loaders are truncated to the border width instead of wrapping; custom editors that do not opt in keep the standalone wrapping indicator.
|
|
139
143
|
- Node.js ≥22.19.0
|
|
140
144
|
|
|
141
145
|
## Development
|
|
@@ -146,7 +150,7 @@ npm test
|
|
|
146
150
|
npm run typecheck
|
|
147
151
|
```
|
|
148
152
|
|
|
149
|
-
Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component,
|
|
153
|
+
Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence and schema migrations, word-pack parsing and loading, the `menu.ts` component, the settings menu wiring end to end, and the flag-gated `/topping-setup` command's install and uninstall flows.
|
|
150
154
|
|
|
151
155
|
## Limitations
|
|
152
156
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
4
4
|
"description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, and an end-of-turn completion marker",
|
|
5
5
|
"keywords": ["pi-package", "pi-extension"],
|
|
6
6
|
"license": "MIT",
|
package/src/activity-meter.ts
CHANGED
|
@@ -123,16 +123,16 @@ export class ActivityMeter {
|
|
|
123
123
|
this.#levels.fill(0);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
/** Colorize a meter cell
|
|
126
|
+
/** Colorize a meter cell (dim at IDLE, `colorize` otherwise). */
|
|
127
127
|
static colorizeCell(
|
|
128
128
|
level: ActivityMeterLevel,
|
|
129
129
|
char: string,
|
|
130
130
|
theme: { fg: (style: ThemeColor, s: string) => string },
|
|
131
|
-
|
|
131
|
+
colorize: (text: string) => string,
|
|
132
132
|
dimmed?: boolean,
|
|
133
133
|
): string {
|
|
134
134
|
if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
|
|
135
|
-
const colored =
|
|
135
|
+
const colored = colorize(char);
|
|
136
136
|
return dimmed ? dimAttribute(colored) : colored;
|
|
137
137
|
}
|
|
138
138
|
}
|
package/src/format.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Theme
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ThinkingLevelColor, ThinkingLevelSettingColor } from "./settings.ts";
|
|
2
3
|
|
|
3
4
|
/** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
|
|
4
5
|
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -6,8 +7,27 @@ export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦",
|
|
|
6
7
|
/** Milliseconds per spinner frame, matching pi-tui's Loader cadence. */
|
|
7
8
|
export const SPINNER_FRAME_MS = 80;
|
|
8
9
|
|
|
9
|
-
/** Default "Working
|
|
10
|
-
export const DEFAULT_WORKING_WORD = "Working
|
|
10
|
+
/** Default "Working" text shown when text randomization/substitution is off. */
|
|
11
|
+
export const DEFAULT_WORKING_WORD = "Working";
|
|
12
|
+
|
|
13
|
+
export type ThinkingLevel = Parameters<Theme["getThinkingBorderColor"]>[0];
|
|
14
|
+
|
|
15
|
+
export const THINKING_LEVEL_VALUES = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
16
|
+
|
|
17
|
+
export function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
|
18
|
+
return typeof value === "string" && THINKING_LEVEL_VALUES.some(level => level === value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build a colorizer for a configured color, including Pi's active thinking-level color. */
|
|
22
|
+
export function getThinkingLevelColorizer(
|
|
23
|
+
theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
|
|
24
|
+
color: ThinkingLevelColor | ThinkingLevelSettingColor,
|
|
25
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
26
|
+
): (text: string) => string {
|
|
27
|
+
return color === "thinking-level"
|
|
28
|
+
? theme.getThinkingBorderColor(thinkingLevel ?? "off")
|
|
29
|
+
: (text: string) => theme.fg(color, text);
|
|
30
|
+
}
|
|
11
31
|
|
|
12
32
|
/** Milliseconds between activity meter sample pushes. */
|
|
13
33
|
export const METER_INTERVAL_MS = 100;
|
|
@@ -24,7 +44,7 @@ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens"
|
|
|
24
44
|
/** Default first-use working-indicator element order. */
|
|
25
45
|
export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens", "responseModel"];
|
|
26
46
|
|
|
27
|
-
/** Elements share a detail group; separators and non-rate details
|
|
47
|
+
/** Elements share a detail group; separators are dimmed and non-rate details use muted. */
|
|
28
48
|
const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate", "responseModel"]);
|
|
29
49
|
|
|
30
50
|
const TOKEN_UNITS = [
|
|
@@ -71,7 +91,7 @@ export function dimAttribute(text: string): string {
|
|
|
71
91
|
return `\x1b[2m${text}\x1b[22m`;
|
|
72
92
|
}
|
|
73
93
|
|
|
74
|
-
/** Number of discrete, eased
|
|
94
|
+
/** Number of discrete, eased shades used by the token-rate and response-model fades. */
|
|
75
95
|
export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
|
|
76
96
|
|
|
77
97
|
/** Milliseconds a settled response model remains fully visible before fading. */
|
|
@@ -80,17 +100,17 @@ export const RESPONSE_MODEL_HOLD_MS = 3_000;
|
|
|
80
100
|
/** Milliseconds over which a settled response model fades through the theme shades. */
|
|
81
101
|
export const RESPONSE_MODEL_FADE_MS = 500;
|
|
82
102
|
|
|
83
|
-
/** Render one
|
|
103
|
+
/** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
|
|
84
104
|
export function fadeThemeColorString(
|
|
85
105
|
text: string,
|
|
86
106
|
shade: number,
|
|
87
|
-
theme: Pick<Theme, "getFgAnsi"
|
|
88
|
-
|
|
107
|
+
theme: Pick<Theme, "getFgAnsi">,
|
|
108
|
+
colorizer: (text: string) => string,
|
|
89
109
|
): string {
|
|
90
110
|
if (!text) return "";
|
|
91
|
-
const source = ansiToRgb(
|
|
111
|
+
const source = ansiToRgb(colorizer("x"));
|
|
92
112
|
const dim = ansiToRgb(theme.getFgAnsi("dim"));
|
|
93
|
-
if (!source || !dim) return
|
|
113
|
+
if (!source || !dim) return colorizer(text);
|
|
94
114
|
const clampedShade = Math.max(0, Math.min(TOKEN_RATE_FADE_SHADE_COUNT - 1, Math.floor(shade)));
|
|
95
115
|
const progress = (clampedShade + 1) / TOKEN_RATE_FADE_SHADE_COUNT;
|
|
96
116
|
const eased = 0.5 * (1 - Math.cos(Math.PI * progress));
|
|
@@ -151,7 +171,7 @@ export function buildWorkingMessage(
|
|
|
151
171
|
if (!value) continue;
|
|
152
172
|
if (DETAIL_ELEMENTS.has(element)) {
|
|
153
173
|
// tokenRate and responseModel arrive pre-colored; dimming them here would replace their configured colors.
|
|
154
|
-
details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("
|
|
174
|
+
details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("muted", value));
|
|
155
175
|
continue;
|
|
156
176
|
}
|
|
157
177
|
flushDetails();
|
|
@@ -220,7 +240,7 @@ export function shimmerString(
|
|
|
220
240
|
}
|
|
221
241
|
|
|
222
242
|
function ansiToRgb(ansi: string): [number, number, number] | null {
|
|
223
|
-
const match = ansi.match(
|
|
243
|
+
const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
|
|
224
244
|
if (!match) return null;
|
|
225
245
|
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
226
246
|
}
|
package/src/menu.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* contract and `ctx.ui.custom()` overlay API.
|
|
5
5
|
*
|
|
6
6
|
* Renders a titled box containing one or more sections of boolean toggle,
|
|
7
|
-
* multi-value cycle, or
|
|
7
|
+
* multi-value cycle, or keyboard-reorderable items, plus an optional live-updating
|
|
8
8
|
* preview section driven by a caller-supplied render callback.
|
|
9
9
|
*
|
|
10
10
|
* Intended to be reused by any extension that needs a simple modal toggle
|
|
@@ -28,6 +28,8 @@ export interface MenuItem {
|
|
|
28
28
|
value: MenuValue;
|
|
29
29
|
/** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
|
|
30
30
|
cycleValues?: readonly string[];
|
|
31
|
+
/** Optional display labels for cycle values; the keys remain the published values. */
|
|
32
|
+
cycleValueLabels?: Readonly<Record<string, string>>;
|
|
31
33
|
/** ID of the boolean value that gates cycling; space toggles it. */
|
|
32
34
|
cycleEnabledBy?: string;
|
|
33
35
|
/** Initial enabled state when `cycleEnabledBy` is set (default true). */
|
|
@@ -55,15 +57,14 @@ export interface MenuConfig {
|
|
|
55
57
|
/**
|
|
56
58
|
* Optional preview renderer, shown in its own "Preview" section above the
|
|
57
59
|
* toggle sections. Called on every render with the menu's current (possibly
|
|
58
|
-
* toggled but not-yet-applied) values
|
|
59
|
-
*
|
|
60
|
-
*
|
|
60
|
+
* toggled but not-yet-applied) values, the milliseconds elapsed since the menu
|
|
61
|
+
* opened, the id of the item under the cursor (undefined for an empty menu),
|
|
62
|
+
* and the width available to each line. Lines may contain ANSI styling and
|
|
63
|
+
* are truncated/padded to fit automatically.
|
|
61
64
|
*
|
|
62
|
-
*
|
|
63
|
-
* refresh), or a `PreviewResult` to declare the next refresh interval.
|
|
64
|
-
* Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
|
|
65
|
+
* Omitting `nextRefreshInMs` (or returning a value <= 0) makes the preview static.
|
|
65
66
|
*/
|
|
66
|
-
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) =>
|
|
67
|
+
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult;
|
|
67
68
|
/** Optional heading for the preview block. Defaults to `Preview`. */
|
|
68
69
|
previewTitle?: string;
|
|
69
70
|
/** Maximum overlay height in rows or as a percentage of the terminal height. */
|
|
@@ -88,12 +89,6 @@ function closeTruncatedHyperlink(text: string): string {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
interface FlatItem {
|
|
91
|
-
id: string;
|
|
92
|
-
label: string;
|
|
93
|
-
cycleValues?: readonly string[];
|
|
94
|
-
cycleEnabledBy?: string;
|
|
95
|
-
cycleDisabledValue?: string;
|
|
96
|
-
reorderGroup?: string;
|
|
97
92
|
item: MenuItem;
|
|
98
93
|
sectionIndex: number;
|
|
99
94
|
}
|
|
@@ -159,7 +154,7 @@ export class MenuComponent implements Component {
|
|
|
159
154
|
this.flat = [];
|
|
160
155
|
for (const [sectionIndex, section] of config.sections.entries()) {
|
|
161
156
|
for (const item of section.items) {
|
|
162
|
-
this.flat.push({
|
|
157
|
+
this.flat.push({ item, sectionIndex });
|
|
163
158
|
}
|
|
164
159
|
}
|
|
165
160
|
this.initialValues = { ...this.values };
|
|
@@ -219,7 +214,7 @@ export class MenuComponent implements Component {
|
|
|
219
214
|
[Key.left]: () => this.cycleCurrentValue(-1),
|
|
220
215
|
[Key.right]: () => this.cycleCurrentValue(1),
|
|
221
216
|
[Key.space]: () => {
|
|
222
|
-
const item = this.flat[this.cursor]
|
|
217
|
+
const item = this.flat[this.cursor]!.item;
|
|
223
218
|
if (item.cycleValues && item.cycleEnabledBy) {
|
|
224
219
|
this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
|
|
225
220
|
if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
|
|
@@ -258,20 +253,20 @@ export class MenuComponent implements Component {
|
|
|
258
253
|
*/
|
|
259
254
|
private moveGrabbedItem(delta: number): boolean {
|
|
260
255
|
const grabbed = this.flat[this.cursor]!;
|
|
261
|
-
const group = grabbed.reorderGroup;
|
|
262
|
-
if (group === undefined || this.values[grabbed.id] !== true) return false;
|
|
256
|
+
const group = grabbed.item.reorderGroup;
|
|
257
|
+
if (group === undefined || this.values[grabbed.item.id] !== true) return false;
|
|
263
258
|
|
|
264
259
|
let start = this.cursor;
|
|
265
|
-
while (start > 0 && this.flat[start - 1]!.reorderGroup === group) start--;
|
|
260
|
+
while (start > 0 && this.flat[start - 1]!.item.reorderGroup === group) start--;
|
|
266
261
|
let end = this.cursor;
|
|
267
|
-
while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
|
|
262
|
+
while (end < this.flat.length - 1 && this.flat[end + 1]!.item.reorderGroup === group) end++;
|
|
268
263
|
|
|
269
264
|
const target = Math.max(start, Math.min(end, this.cursor + delta));
|
|
270
265
|
if (target !== this.cursor) {
|
|
271
266
|
this.flat.splice(this.cursor, 1);
|
|
272
267
|
this.flat.splice(target, 0, grabbed);
|
|
273
268
|
this.cursor = target;
|
|
274
|
-
this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
|
|
269
|
+
this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.item.id).join(",");
|
|
275
270
|
this.invalidate();
|
|
276
271
|
}
|
|
277
272
|
return true;
|
|
@@ -292,7 +287,7 @@ export class MenuComponent implements Component {
|
|
|
292
287
|
}
|
|
293
288
|
|
|
294
289
|
private cycleCurrentValue(delta: number): void {
|
|
295
|
-
const item = this.flat[this.cursor]
|
|
290
|
+
const item = this.flat[this.cursor]!.item;
|
|
296
291
|
if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
297
292
|
const current = item.cycleValues.indexOf(this.values[item.id] as string);
|
|
298
293
|
const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
|
|
@@ -306,14 +301,9 @@ export class MenuComponent implements Component {
|
|
|
306
301
|
const result = this.previewFn(
|
|
307
302
|
this.values,
|
|
308
303
|
this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
|
|
309
|
-
this.flat[this.cursor]?.id,
|
|
304
|
+
this.flat[this.cursor]?.item.id,
|
|
310
305
|
width,
|
|
311
306
|
);
|
|
312
|
-
if (Array.isArray(result)) {
|
|
313
|
-
this.previewNextRefreshMs = 50;
|
|
314
|
-
return result;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
307
|
this.previewNextRefreshMs = result.nextRefreshInMs;
|
|
318
308
|
return result.lines;
|
|
319
309
|
}
|
|
@@ -382,12 +372,7 @@ export class MenuComponent implements Component {
|
|
|
382
372
|
}
|
|
383
373
|
|
|
384
374
|
/** Render the scrolling settings body while keeping the selected item in view. */
|
|
385
|
-
private buildResponsiveToggleSections(innerWidth: number, maxRows: number
|
|
386
|
-
if (allLines.length <= maxRows) {
|
|
387
|
-
this.scrollStart = 0;
|
|
388
|
-
this.pageItemCount = Math.max(1, this.flat.length);
|
|
389
|
-
return allLines;
|
|
390
|
-
}
|
|
375
|
+
private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
|
|
391
376
|
if (maxRows <= 0) {
|
|
392
377
|
this.pageItemCount = 1;
|
|
393
378
|
return [];
|
|
@@ -452,15 +437,16 @@ export class MenuComponent implements Component {
|
|
|
452
437
|
const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
|
|
453
438
|
const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
|
|
454
439
|
const footer = this.buildFooter(innerWidth);
|
|
455
|
-
const
|
|
456
|
-
const
|
|
457
|
-
if (maxRows === undefined ||
|
|
440
|
+
const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size;
|
|
441
|
+
const naturalBodyLength = this.flat.length + 2 * sectionsWithItems;
|
|
442
|
+
if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) {
|
|
443
|
+
const naturalBody = this.buildToggleSections(innerWidth);
|
|
458
444
|
this.pageItemCount = Math.max(1, this.flat.length);
|
|
459
445
|
return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
|
|
460
446
|
}
|
|
461
447
|
|
|
462
448
|
const bodyRows = Math.max(0, maxRows - header.length - footer.length);
|
|
463
|
-
const body = this.buildResponsiveToggleSections(innerWidth, bodyRows
|
|
449
|
+
const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
|
|
464
450
|
return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
|
|
465
451
|
}
|
|
466
452
|
|
|
@@ -557,7 +543,9 @@ export class MenuComponent implements Component {
|
|
|
557
543
|
|
|
558
544
|
if (item.cycleValues) {
|
|
559
545
|
const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
|
|
560
|
-
const
|
|
546
|
+
const rawValue = typeof value === "string" ? value : String(value);
|
|
547
|
+
const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue;
|
|
548
|
+
const stateWord = `‹ ${displayValue} ›`;
|
|
561
549
|
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
|
|
562
550
|
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
563
551
|
const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
|
package/src/pi-installer.ts
CHANGED
|
@@ -9,17 +9,16 @@ export interface PiInstallResult {
|
|
|
9
9
|
const SIGKILL_GRACE_MS = 5_000;
|
|
10
10
|
const EXIT_TIMEOUT = 124;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
12
|
+
function spawnPiPackageCommand(command: "install" | "remove", spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
14
13
|
if (!/^npm:@?[\w.-]+(\/[\w.-]+)?$/.test(spec)) {
|
|
15
14
|
return Promise.resolve({ code: 1, stdout: "", stderr: `invalid spec: ${spec}` });
|
|
16
15
|
}
|
|
17
16
|
return new Promise((resolve) => {
|
|
18
17
|
const isWindows = process.platform === "win32";
|
|
19
|
-
const [
|
|
20
|
-
? (["cmd.exe", ["/c", "pi",
|
|
21
|
-
: (["pi", [
|
|
22
|
-
const processHandle = spawn(
|
|
18
|
+
const [executable, args, options] = isWindows
|
|
19
|
+
? (["cmd.exe", ["/c", "pi", command, spec], { windowsHide: true }] as const)
|
|
20
|
+
: (["pi", [command, spec], {}] as const);
|
|
21
|
+
const processHandle = spawn(executable, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
23
22
|
let stdout = "";
|
|
24
23
|
let stderr = "";
|
|
25
24
|
let settled = false;
|
|
@@ -61,3 +60,13 @@ export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInsta
|
|
|
61
60
|
});
|
|
62
61
|
});
|
|
63
62
|
}
|
|
63
|
+
|
|
64
|
+
/** Run `pi install <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
|
|
65
|
+
export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
66
|
+
return spawnPiPackageCommand("install", spec, timeoutMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Run `pi remove <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
|
|
70
|
+
export function spawnPiRemove(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
71
|
+
return spawnPiPackageCommand("remove", spec, timeoutMs);
|
|
72
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { DEFAULT_PREVIEW_WIDTH, type PreviewResult } from "./menu.ts";
|
|
3
3
|
import { ActivityMeter, rateToLevel } from "./activity-meter.ts";
|
|
4
|
-
import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
|
|
4
|
+
import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
|
|
5
5
|
import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
6
|
-
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isDoneMarkerStyle,
|
|
6
|
+
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
|
|
7
7
|
import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
|
|
8
|
-
import { pickRandomWord } from "./words.ts";
|
|
9
8
|
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
|
|
10
9
|
// flat 28 tps for the token readouts.
|
|
11
10
|
const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
@@ -16,7 +15,6 @@ function meterRate(elapsedMs: number): number { return ((1 - Math.cos((2 * Math.
|
|
|
16
15
|
/** Stateful, per-menu preview renderer that follows the active settings section. */
|
|
17
16
|
export class PreviewRenderer {
|
|
18
17
|
// Preserve constructor-time randomness so rendering never changes the preview phrase.
|
|
19
|
-
readonly #word = pickRandomWord();
|
|
20
18
|
readonly #poolFraction = Math.random();
|
|
21
19
|
readonly #packs: readonly WordPack[];
|
|
22
20
|
readonly #meter = new ActivityMeter();
|
|
@@ -54,17 +52,18 @@ export class PreviewRenderer {
|
|
|
54
52
|
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
|
|
55
53
|
const decorations = { shimmer: values.shimmer !== false, shimmerInverted: values.shimmerInverted === true, tokenActivityMonitor: values.tokenActivityMonitor !== false };
|
|
56
54
|
let spinnerColor = DEFAULT_SETTINGS.decorations.spinnerColor;
|
|
57
|
-
if (
|
|
55
|
+
if (isSpinnerColor(values.spinnerColor)) {
|
|
58
56
|
spinnerColor = values.spinnerColor;
|
|
59
57
|
}
|
|
60
58
|
let spinner = "";
|
|
61
59
|
if (values.animatedSpinner !== false) {
|
|
62
|
-
spinner = this.#ctx.ui.theme
|
|
60
|
+
spinner = getThinkingLevelColorizer(this.#ctx.ui.theme, spinnerColor, this.#ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
|
|
63
61
|
}
|
|
64
62
|
const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
|
|
65
63
|
let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
|
|
66
|
-
if (
|
|
67
|
-
const
|
|
64
|
+
if (isThinkingLevelColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
|
|
65
|
+
const responseModelColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, responseModelColor, this.#ctx.thinkingLevel);
|
|
66
|
+
const responseModelColored = features.responseModel ? responseModelColorizer("test-model") : "";
|
|
68
67
|
const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
|
|
69
68
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
70
69
|
return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, order);
|
|
@@ -74,8 +73,7 @@ export class PreviewRenderer {
|
|
|
74
73
|
const packValues = this.packValues(values);
|
|
75
74
|
const signature = JSON.stringify(Object.keys(packValues).sort().map(k => `${k}=${packValues[k]}`));
|
|
76
75
|
if (signature !== this.#cachedPackSignature) {
|
|
77
|
-
|
|
78
|
-
this.#cachedPackWord = enabledPacks.length ? selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text : this.#word;
|
|
76
|
+
this.#cachedPackWord = selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text;
|
|
79
77
|
this.#cachedPackSignature = signature;
|
|
80
78
|
}
|
|
81
79
|
word = this.#cachedPackWord;
|
|
@@ -87,24 +85,26 @@ export class PreviewRenderer {
|
|
|
87
85
|
styledWord = this.#ctx.ui.theme.fg("text", word);
|
|
88
86
|
}
|
|
89
87
|
let meterColor = DEFAULT_SETTINGS.decorations.meterColor;
|
|
90
|
-
if (
|
|
88
|
+
if (isThinkingLevelColor(values.meterColor)) {
|
|
91
89
|
meterColor = values.meterColor;
|
|
92
90
|
}
|
|
93
91
|
let meter = "";
|
|
94
92
|
if (decorations.tokenActivityMonitor) {
|
|
95
|
-
|
|
93
|
+
const meterColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, meterColor, this.#ctx.thinkingLevel);
|
|
94
|
+
meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColorizer, values.meterDimmed === true));
|
|
96
95
|
}
|
|
97
96
|
let tokenRateText = "";
|
|
98
97
|
if (features.tokenRate) {
|
|
99
98
|
tokenRateText = formatTokenRate(TOKEN_RATE_PER_SEC);
|
|
100
99
|
}
|
|
101
100
|
let tokenRateColor = DEFAULT_SETTINGS.decorations.tokenRateColor;
|
|
102
|
-
if (
|
|
101
|
+
if (isThinkingLevelColor(values.tokenRateColor)) {
|
|
103
102
|
tokenRateColor = values.tokenRateColor;
|
|
104
103
|
}
|
|
105
104
|
let tokenRateColored = "";
|
|
106
105
|
if (tokenRateText) {
|
|
107
|
-
|
|
106
|
+
const tokenRateColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, tokenRateColor, this.#ctx.thinkingLevel);
|
|
107
|
+
tokenRateColored = tokenRateColorizer(tokenRateText);
|
|
108
108
|
}
|
|
109
109
|
let tokenRate = tokenRateColored;
|
|
110
110
|
if (tokenRateColored && values.tokenRateDimmed === true) {
|
|
@@ -157,8 +157,9 @@ export class PreviewRenderer {
|
|
|
157
157
|
icon: values.useNerdFont ? "" : "π",
|
|
158
158
|
provider: this.#ctx.model?.provider,
|
|
159
159
|
model: this.#ctx.model?.id,
|
|
160
|
-
borderColor:
|
|
160
|
+
borderColor: isPromptBorderColor(borderColor) ? borderColor : DEFAULT_SETTINGS.decorations.borderColor,
|
|
161
161
|
borderStyle: isBorderStyle(borderStyle) ? borderStyle : "double",
|
|
162
|
+
thinkingLevel: this.#ctx.thinkingLevel,
|
|
162
163
|
});
|
|
163
164
|
return timestamp === undefined
|
|
164
165
|
? { lines }
|
|
@@ -174,9 +175,9 @@ export class PreviewRenderer {
|
|
|
174
175
|
if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
|
|
175
176
|
const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details);
|
|
176
177
|
const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
|
|
177
|
-
const borderColor =
|
|
178
|
+
const borderColor = isDoneMarkerBorderColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
|
|
178
179
|
const markerStyle = isDoneMarkerStyle(values.doneMarkerStyle) ? values.doneMarkerStyle : DEFAULT_SETTINGS.decorations.doneMarkerStyle;
|
|
179
|
-
const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle);
|
|
180
|
+
const marker = buildCompletionMarkerLine(content, width, theme, borderStyle, borderColor, markerStyle, this.#ctx.thinkingLevel);
|
|
180
181
|
return { lines: ["", marker, ""] };
|
|
181
182
|
}
|
|
182
183
|
}
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { MessageRenderer
|
|
1
|
+
import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
-
import {
|
|
3
|
+
import { getThinkingLevelColorizer, isThinkingLevel, type ThinkingLevel } from "./format.ts";
|
|
4
|
+
import { DEFAULT_SETTINGS, isBorderStyle, isPromptBorderColor, type BorderStyle, type DoneMarkerBorderColor, type DoneMarkerBorderStyle, type DoneMarkerStyle, type PromptBorderColor } from "./settings.ts";
|
|
4
5
|
import { stripControlChars } from "./util.ts";
|
|
5
6
|
|
|
6
7
|
export const PROMPT_BOX_TYPE = "pi-topping-prompt";
|
|
@@ -14,7 +15,10 @@ const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; b
|
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* False visibility options hide each corresponding part; present provider and
|
|
17
|
-
* model values join with `/` in the bottom border.
|
|
18
|
+
* model values join with `/` in the bottom border. `thinkingLevel` is the level
|
|
19
|
+
* captured at submission and is only used when `borderColor` is `thinking-level`.
|
|
20
|
+
* Unrecognised `borderStyle` / `borderColor` values fall back to `double` and the
|
|
21
|
+
* default border color.
|
|
18
22
|
*/
|
|
19
23
|
export interface PromptBoxDetails {
|
|
20
24
|
submittedAt?: number;
|
|
@@ -25,11 +29,15 @@ export interface PromptBoxDetails {
|
|
|
25
29
|
icon?: string;
|
|
26
30
|
provider?: string;
|
|
27
31
|
model?: string;
|
|
28
|
-
borderColor?:
|
|
32
|
+
borderColor?: PromptBorderColor;
|
|
29
33
|
borderStyle?: BorderStyle;
|
|
34
|
+
thinkingLevel?: ThinkingLevel;
|
|
30
35
|
}
|
|
31
36
|
|
|
32
|
-
type PromptTheme = {
|
|
37
|
+
type PromptTheme = {
|
|
38
|
+
fg(color: string, text: string): string;
|
|
39
|
+
getThinkingBorderColor(level: ThinkingLevel): (text: string) => string;
|
|
40
|
+
};
|
|
33
41
|
|
|
34
42
|
const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
|
|
35
43
|
|
|
@@ -51,15 +59,16 @@ export function buildCompletionMarkerLine(
|
|
|
51
59
|
width: number,
|
|
52
60
|
theme: PromptTheme,
|
|
53
61
|
borderStyle: DoneMarkerBorderStyle,
|
|
54
|
-
borderColor:
|
|
62
|
+
borderColor: DoneMarkerBorderColor,
|
|
55
63
|
markerStyle: DoneMarkerStyle = DEFAULT_SETTINGS.decorations.doneMarkerStyle,
|
|
64
|
+
thinkingLevel?: ThinkingLevel,
|
|
56
65
|
): string {
|
|
57
66
|
const safeWidth = Math.max(0, Math.floor(width));
|
|
58
67
|
if (safeWidth === 0) return "";
|
|
59
68
|
if (borderStyle === "none") return truncateToWidth(content, safeWidth);
|
|
60
69
|
|
|
61
70
|
const g = BORDER_GLYPHS[borderStyle];
|
|
62
|
-
const border = (
|
|
71
|
+
const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
|
|
63
72
|
const prefix = `${g.bl}${g.h.repeat(2)} `;
|
|
64
73
|
|
|
65
74
|
const suffix = markerStyle === "bookend"
|
|
@@ -81,8 +90,10 @@ export function buildPromptBoxLines(
|
|
|
81
90
|
|
|
82
91
|
const borderStyle = isBorderStyle(options.borderStyle) ? options.borderStyle : "double";
|
|
83
92
|
const g = BORDER_GLYPHS[borderStyle];
|
|
84
|
-
const borderColor =
|
|
85
|
-
const
|
|
93
|
+
const borderColor = isPromptBorderColor(options.borderColor) ? options.borderColor : DEFAULT_SETTINGS.decorations.borderColor;
|
|
94
|
+
const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
|
|
95
|
+
const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
|
|
96
|
+
const iconColor = (text: string) => theme.fg("text", text);
|
|
86
97
|
const label = (text: string) => theme.fg("customMessageLabel", text);
|
|
87
98
|
const muted = (text: string) => theme.fg("dim", text);
|
|
88
99
|
const bodyText = (text: string) => theme.fg("customMessageText", text);
|
|
@@ -113,7 +124,7 @@ export function buildPromptBoxLines(
|
|
|
113
124
|
const titleLength = icon ? visibleWidth(`${g.h}${g.h} ${icon} `) : 0;
|
|
114
125
|
const timeLength = time ? time.length + 3 : 0; // time + right-side " ═"
|
|
115
126
|
const topFill = Math.max(0, innerWidth - titleLength - timeLength);
|
|
116
|
-
const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${
|
|
127
|
+
const iconSeg = icon ? `${border(`${g.h}${g.h} `)}${iconColor(icon)}${border(" ")}` : "";
|
|
117
128
|
const timeSeg = time ? `${border(" ")}${muted(time)}${border(` ${g.h}`)}` : "";
|
|
118
129
|
const topLine = border(g.tl) + iconSeg + border(g.h.repeat(topFill)) + timeSeg + border(g.tr);
|
|
119
130
|
const lines = [visibleWidth(topLine) > width ? truncateToWidth(topLine, width) : topLine];
|
|
@@ -138,16 +149,19 @@ export function buildPromptBoxLines(
|
|
|
138
149
|
export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
|
|
139
150
|
const content = typeof message.content === "string" ? message.content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "") : "";
|
|
140
151
|
const details = (message.details ?? {}) as PromptBoxDetails;
|
|
141
|
-
|
|
152
|
+
let cachedWidth: number | undefined;
|
|
153
|
+
let cachedLines: string[] | undefined;
|
|
142
154
|
return {
|
|
143
155
|
render(width: number): string[] {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
linesByWidth.set(width, lines);
|
|
156
|
+
if (cachedWidth !== width) {
|
|
157
|
+
cachedWidth = width;
|
|
158
|
+
cachedLines = buildPromptBoxLines(content, details.submittedAt, width, theme, details);
|
|
148
159
|
}
|
|
149
|
-
return
|
|
160
|
+
return cachedLines!;
|
|
161
|
+
},
|
|
162
|
+
invalidate() {
|
|
163
|
+
cachedWidth = undefined;
|
|
164
|
+
cachedLines = undefined;
|
|
150
165
|
},
|
|
151
|
-
invalidate() { linesByWidth.clear(); },
|
|
152
166
|
};
|
|
153
167
|
};
|