@underactive/pi-topping 0.6.4 → 0.7.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 +84 -81
- package/package.json +1 -1
- package/src/activity-meter.ts +3 -3
- package/src/format.ts +30 -10
- package/src/menu.ts +21 -34
- package/src/preview.ts +18 -17
- package/src/prompt-decorator.ts +27 -16
- package/src/session.ts +69 -47
- package/src/settings.ts +75 -55
- package/src/util.ts +9 -0
- package/src/word-packs.ts +5 -4
- package/src/words.ts +2 -10
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
|
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
9
|
-
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
@@ -14,83 +14,91 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
|
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pi install npm:@underactive/pi-topping
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Restart Pi (or run `/reload`) to pick it up.
|
|
24
|
+
|
|
17
25
|
## Settings
|
|
18
26
|
|
|
19
27
|
Run `/topping-settings` (TUI only) to customize your toppings. Settings persist to
|
|
20
28
|
`~/.pi/agent/pi-topping/settings.json`; hand edits are tolerated on load.
|
|
21
29
|
|
|
22
30
|
```
|
|
23
|
-
╔═[ Pi Topping: Settings ]
|
|
24
|
-
╟─ Preview
|
|
25
|
-
║
|
|
26
|
-
║ ⠋ Crafting
|
|
27
|
-
║
|
|
28
|
-
╟─ User Prompt
|
|
29
|
-
║ [■] High-vis prompt ON
|
|
30
|
-
║ [■] Border style ‹ double ›
|
|
31
|
-
║ [■] Border color
|
|
32
|
-
║ [■] Pi icon ON
|
|
33
|
-
║ [■] Timestamp ON
|
|
34
|
-
║ [■] Provider ON
|
|
35
|
-
║ [■] Model ON
|
|
36
|
-
║
|
|
37
|
-
╟─ “Working” Loader
|
|
38
|
-
║ [■] Animated spinner ON
|
|
39
|
-
║ ❯ [■] Animated spinner color
|
|
40
|
-
║ [■] Randomize “Working” text ON
|
|
41
|
-
║ [■] Text shimmer ON
|
|
42
|
-
║ [■] Invert shimmer OFF
|
|
43
|
-
║ [■] Text shimmer direction ‹ Left to Right ›
|
|
44
|
-
║ [■] Text shimmer speed ‹ Normal ›
|
|
45
|
-
║ [■] Token activity monitor ON
|
|
46
|
-
║ [■] Token activity monitor color ‹ accent ›
|
|
47
|
-
║ [■] Token activity monitor direction ‹ Right to Left ›
|
|
48
|
-
║ [■] Token activity monitor dimmed OFF
|
|
49
|
-
║ [■] Elapsed time since prompt ON
|
|
50
|
-
║ [■] Show output tokens ON
|
|
51
|
-
║ [■] Token rate ON
|
|
52
|
-
║ [■] Token rate color ‹ warning ›
|
|
53
|
-
║ [■] Token rate dimmed OFF
|
|
54
|
-
║ [■] Response model ON
|
|
55
|
-
║ [■] Response model color ‹ accent ›
|
|
56
|
-
║ [■] Response model dimmed OFF
|
|
57
|
-
║
|
|
58
|
-
╟─ Elements Order
|
|
59
|
-
║ [ ] Animated spinner
|
|
60
|
-
║ [ ] “Working” text
|
|
61
|
-
║ [ ] Token activity monitor
|
|
62
|
-
║ [ ] Token rate
|
|
63
|
-
║ [ ] Elapsed time
|
|
64
|
-
║ [ ] Output tokens
|
|
65
|
-
║ [ ] Response model
|
|
66
|
-
║
|
|
67
|
-
╟─ Completion Marker
|
|
68
|
-
║ [■] Show completion marker ON
|
|
69
|
-
║ [■] Marker style ‹ elite ›
|
|
70
|
-
║ [■] Border style ‹ none ›
|
|
71
|
-
║ [■] Border color
|
|
72
|
-
║ [■] Pi icon ON
|
|
73
|
-
║ [■] Randomize “Worked” text ON
|
|
74
|
-
║ [■] Tokens spent ON
|
|
75
|
-
║ [■] Mid-turn inputs ON
|
|
76
|
-
║
|
|
77
|
-
╟─ Word Packs
|
|
78
|
-
║ [ ] Doctor Who OFF
|
|
79
|
-
║ [ ] Firefly OFF
|
|
80
|
-
║ [ ] Hitchhiker's Guide OFF
|
|
81
|
-
║ [ ] The Lord of the Rings OFF
|
|
82
|
-
║ [ ] The Matrix OFF
|
|
83
|
-
║ [ ] Portal OFF
|
|
84
|
-
║ [ ] SimCity OFF
|
|
85
|
-
║ [ ] Star Trek OFF
|
|
86
|
-
║ [ ] Star Wars OFF
|
|
87
|
-
║
|
|
88
|
-
╟─ Options
|
|
89
|
-
║ [■] Use NerdFont icons ON
|
|
90
|
-
|
|
91
|
-
║ ↑↓ move PgUp/PgDn page ←→ select
|
|
92
|
-
║ ␣ toggle ⏎ apply esc cancel
|
|
93
|
-
|
|
31
|
+
╔═[ Pi Topping: Settings ]══════════════════════════════════════╗
|
|
32
|
+
╟─ Preview ─────────────────────────────────────────────────────╢
|
|
33
|
+
║ ║
|
|
34
|
+
║ ⠋ Crafting ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
|
|
35
|
+
║ ║
|
|
36
|
+
╟─ User Prompt ─────────────────────────────────────────────────╢
|
|
37
|
+
║ [■] High-vis prompt ON ║
|
|
38
|
+
║ [■] Border style ‹ double › ║
|
|
39
|
+
║ [■] Border color ‹ thinkingLevel › ║
|
|
40
|
+
║ [■] Pi icon ON ║
|
|
41
|
+
║ [■] Timestamp ON ║
|
|
42
|
+
║ [■] Provider ON ║
|
|
43
|
+
║ [■] Model ON ║
|
|
44
|
+
║ ║
|
|
45
|
+
╟─ “Working” Loader ────────────────────────────────────────────╢
|
|
46
|
+
║ [■] Animated spinner ON ║
|
|
47
|
+
║ ❯ [■] Animated spinner color ‹ thinkingLevel › ║
|
|
48
|
+
║ [■] Randomize “Working” text ON ║
|
|
49
|
+
║ [■] Text shimmer ON ║
|
|
50
|
+
║ [■] Invert shimmer OFF ║
|
|
51
|
+
║ [■] Text shimmer direction ‹ Left to Right › ║
|
|
52
|
+
║ [■] Text shimmer speed ‹ Normal › ║
|
|
53
|
+
║ [■] Token activity monitor ON ║
|
|
54
|
+
║ [■] Token activity monitor color ‹ accent › ║
|
|
55
|
+
║ [■] Token activity monitor direction ‹ Right to Left › ║
|
|
56
|
+
║ [■] Token activity monitor dimmed OFF ║
|
|
57
|
+
║ [■] Elapsed time since prompt ON ║
|
|
58
|
+
║ [■] Show output tokens ON ║
|
|
59
|
+
║ [■] Token rate ON ║
|
|
60
|
+
║ [■] Token rate color ‹ warning › ║
|
|
61
|
+
║ [■] Token rate dimmed OFF ║
|
|
62
|
+
║ [■] Response model ON ║
|
|
63
|
+
║ [■] Response model color ‹ accent › ║
|
|
64
|
+
║ [■] Response model dimmed OFF ║
|
|
65
|
+
║ ║
|
|
66
|
+
╟─ Elements Order ──────────────────────────────────────────────╢
|
|
67
|
+
║ [ ] Animated spinner ║
|
|
68
|
+
║ [ ] “Working” text ║
|
|
69
|
+
║ [ ] Token activity monitor ║
|
|
70
|
+
║ [ ] Token rate ║
|
|
71
|
+
║ [ ] Elapsed time ║
|
|
72
|
+
║ [ ] Output tokens ║
|
|
73
|
+
║ [ ] Response model ║
|
|
74
|
+
║ ║
|
|
75
|
+
╟─ Completion Marker ───────────────────────────────────────────╢
|
|
76
|
+
║ [■] Show completion marker ON ║
|
|
77
|
+
║ [■] Marker style ‹ elite › ║
|
|
78
|
+
║ [■] Border style ‹ none › ║
|
|
79
|
+
║ [■] Border color ‹ thinkingLevel › ║
|
|
80
|
+
║ [■] Pi icon ON ║
|
|
81
|
+
║ [■] Randomize “Worked” text ON ║
|
|
82
|
+
║ [■] Tokens spent ON ║
|
|
83
|
+
║ [■] Mid-turn inputs ON ║
|
|
84
|
+
║ ║
|
|
85
|
+
╟─ Word Packs ──────────────────────────────────────────────────╢
|
|
86
|
+
║ [ ] Doctor Who OFF ║
|
|
87
|
+
║ [ ] Firefly OFF ║
|
|
88
|
+
║ [ ] Hitchhiker's Guide OFF ║
|
|
89
|
+
║ [ ] The Lord of the Rings OFF ║
|
|
90
|
+
║ [ ] The Matrix OFF ║
|
|
91
|
+
║ [ ] Portal OFF ║
|
|
92
|
+
║ [ ] SimCity OFF ║
|
|
93
|
+
║ [ ] Star Trek OFF ║
|
|
94
|
+
║ [ ] Star Wars OFF ║
|
|
95
|
+
║ ║
|
|
96
|
+
╟─ Options ─────────────────────────────────────────────────────╢
|
|
97
|
+
║ [■] Use NerdFont icons ON ║
|
|
98
|
+
╟───────────────────────────────────────────────────────────────╢
|
|
99
|
+
║ ↑↓ move PgUp/PgDn page ←→ select ║
|
|
100
|
+
║ ␣ toggle ⏎ apply esc cancel ║
|
|
101
|
+
╚═══════════════════════════════════════════════════════[ 9/50 ]╝
|
|
94
102
|
```
|
|
95
103
|
|
|
96
104
|
### Word packs
|
|
@@ -111,6 +119,8 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
|
|
|
111
119
|
|
|
112
120
|
Pack IDs must start with a lowercase letter and contain only lowercase letters, digits, and `-`. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. 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.
|
|
113
121
|
|
|
122
|
+
The `default` animated spinner and completion marker border colors defer to Pi's thinking-level color. The `thinking-level` prompt border color follows the level captured when the prompt is submitted. The `thinkingLevel` color option for the token activity monitor, token rate, and response model follows Pi's active thinking-level color.
|
|
123
|
+
|
|
114
124
|
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
115
125
|
left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
|
|
116
126
|
`·` separators whenever adjacent. When reordering separates them, each adjacent run is rendered
|
|
@@ -123,19 +133,12 @@ full brightness for 1.5 seconds, then fades through five theme-aware shades to t
|
|
|
123
133
|
the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
|
|
124
134
|
restarts the cycle.
|
|
125
135
|
|
|
126
|
-
The response model is captured from assistant responses, displayed as its sanitized value with no label, and defaults to the final loader detail after output tokens. It can be hidden, recolored, permanently dimmed, or reordered. After the agent settles, its final value remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears; new work cancels the pending fade.
|
|
127
|
-
|
|
128
|
-
## Install
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
pi install npm:@underactive/pi-topping
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Restart Pi (or run `/reload`) to pick it up.
|
|
136
|
+
The response model is captured from assistant responses, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. Equivalent local paths and decorated filenames are suppressed. It can be hidden, recolored, permanently dimmed, or reordered. After the agent settles, its final value remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears; new work cancels the pending fade.
|
|
135
137
|
|
|
136
138
|
## Requirements
|
|
137
139
|
|
|
138
140
|
- [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.
|
|
141
|
+
- 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
142
|
- Node.js ≥22.19.0
|
|
140
143
|
|
|
141
144
|
## Development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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 = [
|
|
@@ -84,13 +104,13 @@ export const RESPONSE_MODEL_FADE_MS = 500;
|
|
|
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
|
@@ -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). */
|
|
@@ -59,11 +61,9 @@ export interface MenuConfig {
|
|
|
59
61
|
* since the menu opened. Lines may contain ANSI styling and are truncated/
|
|
60
62
|
* padded to fit automatically.
|
|
61
63
|
*
|
|
62
|
-
* Return `string[]` for legacy interval-based animation (defaults to 50ms
|
|
63
|
-
* refresh), or a `PreviewResult` to declare the next refresh interval.
|
|
64
64
|
* Omitting `nextRefreshInMs` from a `PreviewResult` makes the preview static.
|
|
65
65
|
*/
|
|
66
|
-
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) =>
|
|
66
|
+
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult;
|
|
67
67
|
/** Optional heading for the preview block. Defaults to `Preview`. */
|
|
68
68
|
previewTitle?: string;
|
|
69
69
|
/** Maximum overlay height in rows or as a percentage of the terminal height. */
|
|
@@ -88,12 +88,6 @@ function closeTruncatedHyperlink(text: string): string {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
interface FlatItem {
|
|
91
|
-
id: string;
|
|
92
|
-
label: string;
|
|
93
|
-
cycleValues?: readonly string[];
|
|
94
|
-
cycleEnabledBy?: string;
|
|
95
|
-
cycleDisabledValue?: string;
|
|
96
|
-
reorderGroup?: string;
|
|
97
91
|
item: MenuItem;
|
|
98
92
|
sectionIndex: number;
|
|
99
93
|
}
|
|
@@ -159,7 +153,7 @@ export class MenuComponent implements Component {
|
|
|
159
153
|
this.flat = [];
|
|
160
154
|
for (const [sectionIndex, section] of config.sections.entries()) {
|
|
161
155
|
for (const item of section.items) {
|
|
162
|
-
this.flat.push({
|
|
156
|
+
this.flat.push({ item, sectionIndex });
|
|
163
157
|
}
|
|
164
158
|
}
|
|
165
159
|
this.initialValues = { ...this.values };
|
|
@@ -219,7 +213,7 @@ export class MenuComponent implements Component {
|
|
|
219
213
|
[Key.left]: () => this.cycleCurrentValue(-1),
|
|
220
214
|
[Key.right]: () => this.cycleCurrentValue(1),
|
|
221
215
|
[Key.space]: () => {
|
|
222
|
-
const item = this.flat[this.cursor]
|
|
216
|
+
const item = this.flat[this.cursor]!.item;
|
|
223
217
|
if (item.cycleValues && item.cycleEnabledBy) {
|
|
224
218
|
this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
|
|
225
219
|
if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
|
|
@@ -258,20 +252,20 @@ export class MenuComponent implements Component {
|
|
|
258
252
|
*/
|
|
259
253
|
private moveGrabbedItem(delta: number): boolean {
|
|
260
254
|
const grabbed = this.flat[this.cursor]!;
|
|
261
|
-
const group = grabbed.reorderGroup;
|
|
262
|
-
if (group === undefined || this.values[grabbed.id] !== true) return false;
|
|
255
|
+
const group = grabbed.item.reorderGroup;
|
|
256
|
+
if (group === undefined || this.values[grabbed.item.id] !== true) return false;
|
|
263
257
|
|
|
264
258
|
let start = this.cursor;
|
|
265
|
-
while (start > 0 && this.flat[start - 1]!.reorderGroup === group) start--;
|
|
259
|
+
while (start > 0 && this.flat[start - 1]!.item.reorderGroup === group) start--;
|
|
266
260
|
let end = this.cursor;
|
|
267
|
-
while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
|
|
261
|
+
while (end < this.flat.length - 1 && this.flat[end + 1]!.item.reorderGroup === group) end++;
|
|
268
262
|
|
|
269
263
|
const target = Math.max(start, Math.min(end, this.cursor + delta));
|
|
270
264
|
if (target !== this.cursor) {
|
|
271
265
|
this.flat.splice(this.cursor, 1);
|
|
272
266
|
this.flat.splice(target, 0, grabbed);
|
|
273
267
|
this.cursor = target;
|
|
274
|
-
this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
|
|
268
|
+
this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.item.id).join(",");
|
|
275
269
|
this.invalidate();
|
|
276
270
|
}
|
|
277
271
|
return true;
|
|
@@ -292,7 +286,7 @@ export class MenuComponent implements Component {
|
|
|
292
286
|
}
|
|
293
287
|
|
|
294
288
|
private cycleCurrentValue(delta: number): void {
|
|
295
|
-
const item = this.flat[this.cursor]
|
|
289
|
+
const item = this.flat[this.cursor]!.item;
|
|
296
290
|
if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
297
291
|
const current = item.cycleValues.indexOf(this.values[item.id] as string);
|
|
298
292
|
const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
|
|
@@ -306,14 +300,9 @@ export class MenuComponent implements Component {
|
|
|
306
300
|
const result = this.previewFn(
|
|
307
301
|
this.values,
|
|
308
302
|
this.previewOrigin !== undefined ? Date.now() - this.previewOrigin : 0,
|
|
309
|
-
this.flat[this.cursor]?.id,
|
|
303
|
+
this.flat[this.cursor]?.item.id,
|
|
310
304
|
width,
|
|
311
305
|
);
|
|
312
|
-
if (Array.isArray(result)) {
|
|
313
|
-
this.previewNextRefreshMs = 50;
|
|
314
|
-
return result;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
306
|
this.previewNextRefreshMs = result.nextRefreshInMs;
|
|
318
307
|
return result.lines;
|
|
319
308
|
}
|
|
@@ -382,12 +371,7 @@ export class MenuComponent implements Component {
|
|
|
382
371
|
}
|
|
383
372
|
|
|
384
373
|
/** 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
|
-
}
|
|
374
|
+
private buildResponsiveToggleSections(innerWidth: number, maxRows: number): string[] {
|
|
391
375
|
if (maxRows <= 0) {
|
|
392
376
|
this.pageItemCount = 1;
|
|
393
377
|
return [];
|
|
@@ -452,15 +436,16 @@ export class MenuComponent implements Component {
|
|
|
452
436
|
const previewLines = this.samplePreview(Math.max(0, innerWidth - 1));
|
|
453
437
|
const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
|
|
454
438
|
const footer = this.buildFooter(innerWidth);
|
|
455
|
-
const
|
|
456
|
-
const
|
|
457
|
-
if (maxRows === undefined ||
|
|
439
|
+
const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size;
|
|
440
|
+
const naturalBodyLength = this.flat.length + 2 * sectionsWithItems;
|
|
441
|
+
if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) {
|
|
442
|
+
const naturalBody = this.buildToggleSections(innerWidth);
|
|
458
443
|
this.pageItemCount = Math.max(1, this.flat.length);
|
|
459
444
|
return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
|
|
460
445
|
}
|
|
461
446
|
|
|
462
447
|
const bodyRows = Math.max(0, maxRows - header.length - footer.length);
|
|
463
|
-
const body = this.buildResponsiveToggleSections(innerWidth, bodyRows
|
|
448
|
+
const body = this.buildResponsiveToggleSections(innerWidth, bodyRows);
|
|
464
449
|
return [...header, ...body, ...footer].slice(0, maxRows).map(line => truncateToWidth(line, boxWidth, ""));
|
|
465
450
|
}
|
|
466
451
|
|
|
@@ -557,7 +542,9 @@ export class MenuComponent implements Component {
|
|
|
557
542
|
|
|
558
543
|
if (item.cycleValues) {
|
|
559
544
|
const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
|
|
560
|
-
const
|
|
545
|
+
const rawValue = typeof value === "string" ? value : String(value);
|
|
546
|
+
const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue;
|
|
547
|
+
const stateWord = `‹ ${displayValue} ›`;
|
|
561
548
|
const maxLabelLen = Math.max(0, innerWidth - ROW_PREFIX_WIDTH - visibleWidth(stateWord) - 1);
|
|
562
549
|
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
563
550
|
const leftPlain = ` ${marker} [${enabled ? "■" : " "}] ${label}`;
|
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
|
}
|