@underactive/pi-topping 0.6.2 → 0.6.4
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 +15 -8
- package/package.json +3 -3
- package/src/format.ts +13 -7
- package/src/menu.ts +57 -31
- package/src/preview.ts +9 -4
- package/src/session.ts +178 -28
- package/src/settings.ts +11 -2
- package/src/setup-command.ts +1 -1
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 (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display,
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer (with direction, speed, and invert option), token activity monitor (with color, direction, and dim toggle), elapsed timer, output token display, live output-token rate (with color choice and dim toggle), and the response model (with visibility, color, and dim controls) — 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
|
|
|
@@ -23,7 +23,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
23
23
|
╔═[ Pi Topping: Settings ]═══════════════════════════════════╗
|
|
24
24
|
╟─ Preview ──────────────────────────────────────────────────╢
|
|
25
25
|
║ ║
|
|
26
|
-
║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28
|
|
26
|
+
║ ⠋ Crafting… ⣠⣤⣶⣶⣤⣠⣀⢀ 28 tps · 3s · ↓ 84 tokens · test-model ║
|
|
27
27
|
║ ║
|
|
28
28
|
╟─ User Prompt ──────────────────────────────────────────────╢
|
|
29
29
|
║ [■] High-vis prompt ON ║
|
|
@@ -51,6 +51,9 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
51
51
|
║ [■] Token rate ON ║
|
|
52
52
|
║ [■] Token rate color ‹ warning › ║
|
|
53
53
|
║ [■] Token rate dimmed OFF ║
|
|
54
|
+
║ [■] Response model ON ║
|
|
55
|
+
║ [■] Response model color ‹ accent › ║
|
|
56
|
+
║ [■] Response model dimmed OFF ║
|
|
54
57
|
║ ║
|
|
55
58
|
╟─ Elements Order ───────────────────────────────────────────╢
|
|
56
59
|
║ [ ] Animated spinner ║
|
|
@@ -59,6 +62,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
59
62
|
║ [ ] Token rate ║
|
|
60
63
|
║ [ ] Elapsed time ║
|
|
61
64
|
║ [ ] Output tokens ║
|
|
65
|
+
║ [ ] Response model ║
|
|
62
66
|
║ ║
|
|
63
67
|
╟─ Completion Marker ────────────────────────────────────────╢
|
|
64
68
|
║ [■] Show completion marker ON ║
|
|
@@ -84,8 +88,9 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
84
88
|
╟─ Options ──────────────────────────────────────────────────╢
|
|
85
89
|
║ [■] Use NerdFont icons ON ║
|
|
86
90
|
╟────────────────────────────────────────────────────────────╢
|
|
87
|
-
║ ↑↓ move ←→ select
|
|
88
|
-
|
|
91
|
+
║ ↑↓ move PgUp/PgDn page ←→ select ║
|
|
92
|
+
║ ␣ toggle ⏎ apply esc cancel ║
|
|
93
|
+
╚════════════════════════════════════════════════════[ 9/50 ]╝
|
|
89
94
|
```
|
|
90
95
|
|
|
91
96
|
### Word packs
|
|
@@ -107,17 +112,19 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
|
|
|
107
112
|
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.
|
|
108
113
|
|
|
109
114
|
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
110
|
-
left or right within the loader. Elapsed time, output tokens,
|
|
115
|
+
left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
|
|
111
116
|
`·` separators whenever adjacent. When reordering separates them, each adjacent run is rendered
|
|
112
117
|
without a separator before or after it.
|
|
113
118
|
|
|
114
|
-
The `N
|
|
119
|
+
The `N tps` token rate uses the selected theme color and
|
|
115
120
|
remains reorderable. Active rates below 1,000 are padded to three characters so updates do not shift the other
|
|
116
|
-
segments; larger rates may shift later segments. When inactive, it remains as the dim `---
|
|
121
|
+
segments; larger rates may shift later segments. When inactive, it remains as the dim `--- tps` placeholder. After its last update it holds
|
|
117
122
|
full brightness for 1.5 seconds, then fades through five theme-aware shades to the dim text color over
|
|
118
123
|
the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
|
|
119
124
|
restarts the cycle.
|
|
120
125
|
|
|
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
|
+
|
|
121
128
|
## Install
|
|
122
129
|
|
|
123
130
|
```bash
|
|
@@ -128,7 +135,7 @@ Restart Pi (or run `/reload`) to pick it up.
|
|
|
128
135
|
|
|
129
136
|
## Requirements
|
|
130
137
|
|
|
131
|
-
- [Pi](https://github.com/earendil-works/pi-coding-agent)
|
|
138
|
+
- [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.
|
|
132
139
|
- Node.js ≥22.19.0
|
|
133
140
|
|
|
134
141
|
## Development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
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",
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"@earendil-works/pi-tui": "*"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
34
|
-
"@earendil-works/pi-tui": "0.
|
|
33
|
+
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
34
|
+
"@earendil-works/pi-tui": "0.84.4",
|
|
35
35
|
"@types/node": "^24.0.0",
|
|
36
36
|
"typescript": "^5.9.3"
|
|
37
37
|
},
|
package/src/format.ts
CHANGED
|
@@ -19,13 +19,13 @@ export const SHIMMER_INTERVAL_MS = 50;
|
|
|
19
19
|
export const ELAPSED_INTERVAL_MS = 1_000;
|
|
20
20
|
|
|
21
21
|
/** A user-orderable piece of the working indicator. */
|
|
22
|
-
export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens" | "tokenRate";
|
|
22
|
+
export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens" | "tokenRate" | "responseModel";
|
|
23
23
|
|
|
24
24
|
/** Default first-use working-indicator element order. */
|
|
25
|
-
export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens"];
|
|
25
|
+
export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens", "responseModel"];
|
|
26
26
|
|
|
27
27
|
/** Elements share a detail group; separators and non-rate details are dimmed. */
|
|
28
|
-
const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate"]);
|
|
28
|
+
const DETAIL_ELEMENTS: ReadonlySet<LoaderElement> = new Set(["elapsed", "tokens", "tokenRate", "responseModel"]);
|
|
29
29
|
|
|
30
30
|
const TOKEN_UNITS = [
|
|
31
31
|
{ threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
|
|
@@ -52,12 +52,12 @@ export function formatTokens(count: number): string {
|
|
|
52
52
|
const TOKEN_RATE_DIGITS = 3;
|
|
53
53
|
|
|
54
54
|
/** Placeholder shown when the working indicator has no active token-rate sample. */
|
|
55
|
-
export const TOKEN_RATE_PLACEHOLDER = `${"-".repeat(TOKEN_RATE_DIGITS)}
|
|
55
|
+
export const TOKEN_RATE_PLACEHOLDER = `${"-".repeat(TOKEN_RATE_DIGITS)} tps`;
|
|
56
56
|
|
|
57
57
|
/** Format an output-token throughput estimate for the working indicator. */
|
|
58
58
|
export function formatTokenRate(rate: number): string {
|
|
59
59
|
const rounded = Math.round(rate);
|
|
60
|
-
return rounded === 0 ? "" : `${rounded.toString().padStart(TOKEN_RATE_DIGITS)}
|
|
60
|
+
return rounded === 0 ? "" : `${rounded.toString().padStart(TOKEN_RATE_DIGITS)} tps`;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
/**
|
|
@@ -74,6 +74,12 @@ export function dimAttribute(text: string): string {
|
|
|
74
74
|
/** Number of discrete, eased warning-to-dim shades used for the token-rate fade. */
|
|
75
75
|
export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
|
|
76
76
|
|
|
77
|
+
/** Milliseconds a settled response model remains fully visible before fading. */
|
|
78
|
+
export const RESPONSE_MODEL_HOLD_MS = 3_000;
|
|
79
|
+
|
|
80
|
+
/** Milliseconds over which a settled response model fades through the theme shades. */
|
|
81
|
+
export const RESPONSE_MODEL_FADE_MS = 500;
|
|
82
|
+
|
|
77
83
|
/** Render one token-rate fade shade by blending the selected color toward the active theme's dim color. */
|
|
78
84
|
export function fadeThemeColorString(
|
|
79
85
|
text: string,
|
|
@@ -144,8 +150,8 @@ export function buildWorkingMessage(
|
|
|
144
150
|
const value = parts[element];
|
|
145
151
|
if (!value) continue;
|
|
146
152
|
if (DETAIL_ELEMENTS.has(element)) {
|
|
147
|
-
// tokenRate
|
|
148
|
-
details.push(element === "tokenRate" ? value : theme.fg("dim", value));
|
|
153
|
+
// tokenRate and responseModel arrive pre-colored; dimming them here would replace their configured colors.
|
|
154
|
+
details.push(element === "tokenRate" || element === "responseModel" ? value : theme.fg("dim", value));
|
|
149
155
|
continue;
|
|
150
156
|
}
|
|
151
157
|
flushDetails();
|
package/src/menu.ts
CHANGED
|
@@ -5,27 +5,14 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Renders a titled box containing one or more sections of boolean toggle,
|
|
7
7
|
* multi-value cycle, or drag-to-reorder items, plus an optional live-updating
|
|
8
|
-
* preview section driven by a caller-supplied render callback
|
|
9
|
-
*
|
|
10
|
-
* ╔═[ Pi Topping: Settings ]════════════════════════╗
|
|
11
|
-
* ╟─ Preview ────────────────────────────────────╢
|
|
12
|
-
* ║ ║
|
|
13
|
-
* ║ ⠋ Cerebrating… ⣤⣤⣤⣤ 28 tok/s · 3s · ↓ 84 tokens ║
|
|
14
|
-
* ║ ║
|
|
15
|
-
* ╟─ Decorations ───────────────────────────────────╢
|
|
16
|
-
* ║ ❯ [■] Animated spinner ON ║
|
|
17
|
-
* ║ [ ] "Working..." text shimmer OFF ║
|
|
18
|
-
* ║ ║
|
|
19
|
-
* ╟──────────────────────────────────────────────────╢
|
|
20
|
-
* ║ ↑↓ move ␣ toggle ⏎ apply esc cancel ║
|
|
21
|
-
* ╚══════════════════════════════════════════[ 1/2 ]═╝
|
|
8
|
+
* preview section driven by a caller-supplied render callback.
|
|
22
9
|
*
|
|
23
10
|
* Intended to be reused by any extension that needs a simple modal toggle
|
|
24
11
|
* menu; it has no dependency on this extension's own settings shape.
|
|
25
12
|
*/
|
|
26
13
|
|
|
27
14
|
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
28
|
-
import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { type Component, Key, matchesKey, truncateToWidth, type SizeValue, type TUI, visibleWidth } from "@earendil-works/pi-tui";
|
|
29
16
|
|
|
30
17
|
export type MenuValue = boolean | string;
|
|
31
18
|
|
|
@@ -79,6 +66,8 @@ export interface MenuConfig {
|
|
|
79
66
|
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => string[] | PreviewResult;
|
|
80
67
|
/** Optional heading for the preview block. Defaults to `Preview`. */
|
|
81
68
|
previewTitle?: string;
|
|
69
|
+
/** Maximum overlay height in rows or as a percentage of the terminal height. */
|
|
70
|
+
maxHeight?: SizeValue;
|
|
82
71
|
}
|
|
83
72
|
|
|
84
73
|
export interface MenuResult<T> {
|
|
@@ -86,7 +75,7 @@ export interface MenuResult<T> {
|
|
|
86
75
|
values: T;
|
|
87
76
|
}
|
|
88
77
|
|
|
89
|
-
const DEFAULT_HINTS = ["\u2191\u2193 move", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
78
|
+
const DEFAULT_HINTS = ["\u2191\u2193 move", "PgUp/PgDn page", "\u2423 toggle", "\u23ce apply", "esc cancel"];
|
|
90
79
|
const OVERLAY_WIDTH = "86%";
|
|
91
80
|
export const DEFAULT_PREVIEW_WIDTH = 76;
|
|
92
81
|
const ROW_PREFIX_WIDTH = 8; // " " + marker + " " + "[" + box + "]" + " "
|
|
@@ -141,6 +130,7 @@ export class MenuComponent implements Component {
|
|
|
141
130
|
private readonly previewFn: MenuConfig["preview"];
|
|
142
131
|
private readonly previewOrigin: number | undefined;
|
|
143
132
|
private readonly tui: TUI | undefined;
|
|
133
|
+
private readonly maxHeight: SizeValue | undefined;
|
|
144
134
|
private previewTimer: ReturnType<typeof setTimeout> | undefined;
|
|
145
135
|
private previewNextRefreshMs: number | undefined;
|
|
146
136
|
private disposed = false;
|
|
@@ -149,6 +139,7 @@ export class MenuComponent implements Component {
|
|
|
149
139
|
private cachedWidth: number | undefined;
|
|
150
140
|
private cachedRows: number | undefined;
|
|
151
141
|
private cachedLines: string[] | undefined;
|
|
142
|
+
private pageItemCount: number | undefined;
|
|
152
143
|
|
|
153
144
|
constructor(
|
|
154
145
|
config: MenuConfig,
|
|
@@ -163,6 +154,7 @@ export class MenuComponent implements Component {
|
|
|
163
154
|
this.hints = config.hints ?? DEFAULT_HINTS;
|
|
164
155
|
this.previewTitle = config.previewTitle ?? "Preview";
|
|
165
156
|
this.tui = tui;
|
|
157
|
+
this.maxHeight = config.maxHeight;
|
|
166
158
|
this.values = buildInitialValues(config);
|
|
167
159
|
this.flat = [];
|
|
168
160
|
for (const [sectionIndex, section] of config.sections.entries()) {
|
|
@@ -203,7 +195,7 @@ export class MenuComponent implements Component {
|
|
|
203
195
|
|
|
204
196
|
// Map input to a normalized key name.
|
|
205
197
|
let mappedKey: string | undefined;
|
|
206
|
-
for (const k of [Key.up, Key.down, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
|
|
198
|
+
for (const k of [Key.up, Key.down, Key.pageUp, Key.pageDown, Key.left, Key.right, Key.space, Key.enter, Key.escape]) {
|
|
207
199
|
if (matchesKey(data, k)) {
|
|
208
200
|
mappedKey = k;
|
|
209
201
|
break;
|
|
@@ -214,14 +206,16 @@ export class MenuComponent implements Component {
|
|
|
214
206
|
const keyActions: Record<string, () => void> = {
|
|
215
207
|
[Key.up]: () => {
|
|
216
208
|
if (this.moveGrabbedItem(-1)) return;
|
|
217
|
-
this.cursor = (this.cursor - 1
|
|
209
|
+
this.cursor = Math.max(0, this.cursor - 1);
|
|
218
210
|
this.invalidate();
|
|
219
211
|
},
|
|
220
212
|
[Key.down]: () => {
|
|
221
213
|
if (this.moveGrabbedItem(1)) return;
|
|
222
|
-
this.cursor = (this.cursor + 1)
|
|
214
|
+
this.cursor = Math.min(this.flat.length - 1, this.cursor + 1);
|
|
223
215
|
this.invalidate();
|
|
224
216
|
},
|
|
217
|
+
[Key.pageUp]: () => this.moveByPage(-1),
|
|
218
|
+
[Key.pageDown]: () => this.moveByPage(1),
|
|
225
219
|
[Key.left]: () => this.cycleCurrentValue(-1),
|
|
226
220
|
[Key.right]: () => this.cycleCurrentValue(1),
|
|
227
221
|
[Key.space]: () => {
|
|
@@ -258,9 +252,9 @@ export class MenuComponent implements Component {
|
|
|
258
252
|
}
|
|
259
253
|
|
|
260
254
|
/**
|
|
261
|
-
* Move the grabbed reorder row
|
|
262
|
-
*
|
|
263
|
-
*
|
|
255
|
+
* Move the grabbed reorder row within its group, clamped at the group edges.
|
|
256
|
+
* Returns true when the key belongs to the mover and must not also move the
|
|
257
|
+
* cursor — which is what keeps a grab from escaping its group.
|
|
264
258
|
*/
|
|
265
259
|
private moveGrabbedItem(delta: number): boolean {
|
|
266
260
|
const grabbed = this.flat[this.cursor]!;
|
|
@@ -272,10 +266,10 @@ export class MenuComponent implements Component {
|
|
|
272
266
|
let end = this.cursor;
|
|
273
267
|
while (end < this.flat.length - 1 && this.flat[end + 1]!.reorderGroup === group) end++;
|
|
274
268
|
|
|
275
|
-
const target = this.cursor + delta;
|
|
276
|
-
if (target
|
|
277
|
-
this.flat
|
|
278
|
-
this.flat
|
|
269
|
+
const target = Math.max(start, Math.min(end, this.cursor + delta));
|
|
270
|
+
if (target !== this.cursor) {
|
|
271
|
+
this.flat.splice(this.cursor, 1);
|
|
272
|
+
this.flat.splice(target, 0, grabbed);
|
|
279
273
|
this.cursor = target;
|
|
280
274
|
this.values[group] = this.flat.slice(start, end + 1).map(flat => flat.id).join(",");
|
|
281
275
|
this.invalidate();
|
|
@@ -283,6 +277,20 @@ export class MenuComponent implements Component {
|
|
|
283
277
|
return true;
|
|
284
278
|
}
|
|
285
279
|
|
|
280
|
+
private moveByPage(direction: -1 | 1): void {
|
|
281
|
+
const pageItemCount = this.getPageItemCount();
|
|
282
|
+
if (this.moveGrabbedItem(direction * pageItemCount)) return;
|
|
283
|
+
this.cursor = Math.max(0, Math.min(this.flat.length - 1, this.cursor + direction * pageItemCount));
|
|
284
|
+
this.invalidate();
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private getPageItemCount(): number {
|
|
288
|
+
if (this.pageItemCount !== undefined) return this.pageItemCount;
|
|
289
|
+
const rows = this.availableRows();
|
|
290
|
+
if (rows === undefined) return Math.max(1, Math.min(this.flat.length, 10));
|
|
291
|
+
return Math.max(1, rows - 6);
|
|
292
|
+
}
|
|
293
|
+
|
|
286
294
|
private cycleCurrentValue(delta: number): void {
|
|
287
295
|
const item = this.flat[this.cursor]!;
|
|
288
296
|
if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
@@ -377,10 +385,17 @@ export class MenuComponent implements Component {
|
|
|
377
385
|
private buildResponsiveToggleSections(innerWidth: number, maxRows: number, allLines: string[]): string[] {
|
|
378
386
|
if (allLines.length <= maxRows) {
|
|
379
387
|
this.scrollStart = 0;
|
|
388
|
+
this.pageItemCount = Math.max(1, this.flat.length);
|
|
380
389
|
return allLines;
|
|
381
390
|
}
|
|
382
|
-
if (maxRows <= 0)
|
|
383
|
-
|
|
391
|
+
if (maxRows <= 0) {
|
|
392
|
+
this.pageItemCount = 1;
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
if (maxRows === 1) {
|
|
396
|
+
this.pageItemCount = 1;
|
|
397
|
+
return this.buildToggleWindow(innerWidth, this.cursor, 1).lines;
|
|
398
|
+
}
|
|
384
399
|
|
|
385
400
|
const contentRows = maxRows - 1; // Reserve one fixed row for scroll status.
|
|
386
401
|
if (this.cursor < this.scrollStart) this.scrollStart = this.cursor;
|
|
@@ -405,6 +420,7 @@ export class MenuComponent implements Component {
|
|
|
405
420
|
}
|
|
406
421
|
}
|
|
407
422
|
const window = this.buildToggleWindow(innerWidth, this.scrollStart, contentRows);
|
|
423
|
+
this.pageItemCount = Math.max(1, window.end - this.scrollStart + 1);
|
|
408
424
|
|
|
409
425
|
const rows = [...window.lines];
|
|
410
426
|
while (rows.length < contentRows) rows.push(this.renderBlankRow(innerWidth));
|
|
@@ -417,8 +433,16 @@ export class MenuComponent implements Component {
|
|
|
417
433
|
}
|
|
418
434
|
|
|
419
435
|
private availableRows(): number | undefined {
|
|
420
|
-
const rows =
|
|
421
|
-
|
|
436
|
+
const rows = this.tui?.terminal?.rows;
|
|
437
|
+
if (typeof rows !== "number" || !Number.isFinite(rows) || rows <= 0) return undefined;
|
|
438
|
+
|
|
439
|
+
const terminalRows = Math.floor(rows);
|
|
440
|
+
if (this.maxHeight === undefined) return terminalRows;
|
|
441
|
+
const requestedRows = typeof this.maxHeight === "number"
|
|
442
|
+
? this.maxHeight
|
|
443
|
+
: (Number.parseFloat(this.maxHeight) / 100) * terminalRows;
|
|
444
|
+
if (!Number.isFinite(requestedRows)) return terminalRows;
|
|
445
|
+
return Math.max(1, Math.min(terminalRows, Math.floor(requestedRows)));
|
|
422
446
|
}
|
|
423
447
|
|
|
424
448
|
private buildLines(maxWidth: number, maxRows?: number): string[] {
|
|
@@ -431,6 +455,7 @@ export class MenuComponent implements Component {
|
|
|
431
455
|
const naturalBody = this.buildToggleSections(innerWidth);
|
|
432
456
|
const naturalHeight = header.length + naturalBody.length + footer.length;
|
|
433
457
|
if (maxRows === undefined || naturalHeight <= maxRows) {
|
|
458
|
+
this.pageItemCount = Math.max(1, this.flat.length);
|
|
434
459
|
return [...header, ...naturalBody, ...footer].map(line => truncateToWidth(line, boxWidth, ""));
|
|
435
460
|
}
|
|
436
461
|
|
|
@@ -571,9 +596,10 @@ export async function showMenu<T extends Record<string, MenuValue>>(
|
|
|
571
596
|
return { applied: false, values: initialValues };
|
|
572
597
|
}
|
|
573
598
|
|
|
599
|
+
const maxHeight = config.maxHeight ?? "100%";
|
|
574
600
|
return ctx.ui.custom<MenuResult<T>>(
|
|
575
601
|
(tui, theme, _keybindings, done) =>
|
|
576
602
|
new MenuComponent(config, theme, done as (result: MenuResult<Record<string, MenuValue>>) => void, tui),
|
|
577
|
-
{ overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight
|
|
603
|
+
{ overlay: true, overlayOptions: { width: OVERLAY_WIDTH, maxHeight } },
|
|
578
604
|
);
|
|
579
605
|
}
|
package/src/preview.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBox
|
|
|
6
6
|
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderStyle, isDoneMarkerStyle, isSettingColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
|
|
7
7
|
import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
|
|
8
8
|
import { pickRandomWord } from "./words.ts";
|
|
9
|
-
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46
|
|
10
|
-
// flat 28
|
|
9
|
+
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
|
|
10
|
+
// flat 28 tps for the token readouts.
|
|
11
11
|
const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
12
12
|
const PROMPT_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "User Prompt").map(entry => entry.id));
|
|
13
13
|
const MARKER_IDS = new Set(MENU_ENTRIES.filter(entry => entry.section === "Completion Marker").map(entry => entry.id));
|
|
@@ -51,7 +51,7 @@ export class PreviewRenderer {
|
|
|
51
51
|
this.#meter.push(rateToLevel(meterRate(elapsedMs)));
|
|
52
52
|
this.#lastMeterUpdate = elapsedMs;
|
|
53
53
|
}
|
|
54
|
-
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false };
|
|
54
|
+
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
|
|
55
55
|
const decorations = { shimmer: values.shimmer !== false, shimmerInverted: values.shimmerInverted === true, tokenActivityMonitor: values.tokenActivityMonitor !== false };
|
|
56
56
|
let spinnerColor = DEFAULT_SETTINGS.decorations.spinnerColor;
|
|
57
57
|
if (values.spinnerColorEnabled !== false && isSettingColor(values.spinnerColor)) {
|
|
@@ -62,8 +62,12 @@ export class PreviewRenderer {
|
|
|
62
62
|
spinner = this.#ctx.ui.theme.fg(spinnerColor, SPINNER_FRAMES[Math.floor(elapsedMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
|
|
63
63
|
}
|
|
64
64
|
const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
|
|
65
|
+
let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
|
|
66
|
+
if (isSettingColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
|
|
67
|
+
const responseModelColored = features.responseModel ? this.#ctx.ui.theme.fg(responseModelColor, "test-model") : "";
|
|
68
|
+
const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
|
|
65
69
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
66
|
-
return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, order);
|
|
70
|
+
return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, order);
|
|
67
71
|
}
|
|
68
72
|
let word = DEFAULT_WORKING_WORD;
|
|
69
73
|
if (features.substituteDefaultMessage) {
|
|
@@ -121,6 +125,7 @@ export class PreviewRenderer {
|
|
|
121
125
|
elapsed,
|
|
122
126
|
tokens,
|
|
123
127
|
tokenRate,
|
|
128
|
+
responseModel,
|
|
124
129
|
}, order);
|
|
125
130
|
}
|
|
126
131
|
private packValues(values: Record<string, boolean | string>): Record<string, boolean> {
|
package/src/session.ts
CHANGED
|
@@ -25,6 +25,8 @@ import {
|
|
|
25
25
|
formatTokens,
|
|
26
26
|
isFullyDefaultAppearance,
|
|
27
27
|
METER_INTERVAL_MS,
|
|
28
|
+
RESPONSE_MODEL_FADE_MS,
|
|
29
|
+
RESPONSE_MODEL_HOLD_MS,
|
|
28
30
|
SHIMMER_INTERVAL_MS,
|
|
29
31
|
shimmerString,
|
|
30
32
|
SPINNER_FRAME_MS,
|
|
@@ -47,8 +49,21 @@ type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
|
|
|
47
49
|
type MessageUpdateEvent = Extract<ExtensionEvent, { type: "message_update" }>;
|
|
48
50
|
type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
|
|
49
51
|
type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
|
|
52
|
+
type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
|
|
53
|
+
type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
|
|
54
|
+
type UIPromptKind = UIPromptStartEvent["kind"];
|
|
50
55
|
|
|
51
56
|
const DONE_ENTRY_TYPE = "pi-topping-done";
|
|
57
|
+
const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
|
|
58
|
+
const WAITING_LABELS: Record<UIPromptKind, string> = {
|
|
59
|
+
select: "Waiting for selection",
|
|
60
|
+
confirm: "Waiting for confirmation",
|
|
61
|
+
input: "Waiting for input",
|
|
62
|
+
editor: "Waiting in editor",
|
|
63
|
+
custom: "Waiting for input",
|
|
64
|
+
};
|
|
65
|
+
const WAITING_PULSE_FRAMES = ["·", "•", "●", "•"];
|
|
66
|
+
const WAITING_PULSE_INTERVAL_MS = 120;
|
|
52
67
|
// Reconciliation resets the EMA at message boundaries, so retain and then fade the last rate to avoid flicker.
|
|
53
68
|
const TOKEN_RATE_HOLD_MS = 1_500;
|
|
54
69
|
const TOKEN_RATE_FADE_MS = 250;
|
|
@@ -70,10 +85,15 @@ interface SessionState {
|
|
|
70
85
|
rateTracker: TokRateTracker;
|
|
71
86
|
lastTokenRateSampledAt: number;
|
|
72
87
|
lastTokenRateTotal: number;
|
|
88
|
+
responseModel: string;
|
|
89
|
+
responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
|
|
90
|
+
responseModelFadeTimer: ReturnType<typeof setInterval> | null;
|
|
91
|
+
responseModelFadeGeneration: number;
|
|
73
92
|
tokenRateText: string;
|
|
74
93
|
tokenRateFadeStartsAt: number;
|
|
75
94
|
timer: ReturnType<typeof setInterval> | null;
|
|
76
95
|
busy: boolean;
|
|
96
|
+
waiting: { kind: UIPromptKind; title?: string } | null;
|
|
77
97
|
midTurnInputs: number;
|
|
78
98
|
/** Last string passed to setWorkingMessage(), or the NOT_SENT sentinel before the first call. */
|
|
79
99
|
lastMessage: string | undefined | typeof NOT_SENT;
|
|
@@ -102,15 +122,25 @@ function makeFreshState(): SessionState {
|
|
|
102
122
|
rateTracker: new TokRateTracker(),
|
|
103
123
|
lastTokenRateSampledAt: 0,
|
|
104
124
|
lastTokenRateTotal: 0,
|
|
125
|
+
responseModel: "",
|
|
126
|
+
responseModelHoldTimer: null,
|
|
127
|
+
responseModelFadeTimer: null,
|
|
128
|
+
responseModelFadeGeneration: 0,
|
|
105
129
|
tokenRateText: "",
|
|
106
130
|
tokenRateFadeStartsAt: 0,
|
|
107
131
|
timer: null,
|
|
108
132
|
busy: false,
|
|
133
|
+
waiting: null,
|
|
109
134
|
midTurnInputs: 0,
|
|
110
135
|
lastMessage: NOT_SENT,
|
|
111
136
|
};
|
|
112
137
|
}
|
|
113
138
|
|
|
139
|
+
function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
|
|
140
|
+
const title = waiting.title ? stripControlChars(waiting.title).trim() : "";
|
|
141
|
+
return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
|
|
142
|
+
}
|
|
143
|
+
|
|
114
144
|
/** Owns mutable extension state and Pi lifecycle registrations. */
|
|
115
145
|
export class SessionManager {
|
|
116
146
|
#counter = new StreamingWordCounter();
|
|
@@ -126,13 +156,14 @@ export class SessionManager {
|
|
|
126
156
|
}
|
|
127
157
|
|
|
128
158
|
#onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
159
|
+
this.#currentCtx = ctx;
|
|
129
160
|
this.stopTimer();
|
|
161
|
+
this.cancelResponseModelFade(ctx);
|
|
130
162
|
this.#counter.reset();
|
|
131
163
|
this.#state = makeFreshState();
|
|
132
164
|
this.#settings = loadSettings();
|
|
133
165
|
this.#userPacks = loadUserWordPacks();
|
|
134
166
|
this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
|
|
135
|
-
this.#currentCtx = ctx;
|
|
136
167
|
if (this.usable(ctx)) {
|
|
137
168
|
this.applyIndicator(ctx);
|
|
138
169
|
if (isSiblingSetupEnabled()) notifyMissingToppingsOnce(this.#pi, ctx.ui);
|
|
@@ -146,32 +177,38 @@ export class SessionManager {
|
|
|
146
177
|
if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
|
|
147
178
|
else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
|
|
148
179
|
if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
180
|
+
try {
|
|
181
|
+
await this.#pi.sendMessage<PromptBoxDetails>(
|
|
182
|
+
{
|
|
183
|
+
customType: PROMPT_BOX_TYPE,
|
|
184
|
+
content: event.text,
|
|
185
|
+
display: true,
|
|
186
|
+
details: {
|
|
187
|
+
submittedAt: Date.now(),
|
|
188
|
+
showIcon: this.#settings.decorations.promptIcon,
|
|
189
|
+
showTimestamp: this.#settings.decorations.promptTimestamp,
|
|
190
|
+
showProvider: this.#settings.decorations.promptProvider,
|
|
191
|
+
showModel: this.#settings.decorations.promptModel,
|
|
192
|
+
icon: this.#settings.decorations.useNerdFont ? "" : "π",
|
|
193
|
+
provider: ctx.model?.provider,
|
|
194
|
+
model: ctx.model?.id,
|
|
195
|
+
borderColor: this.#settings.decorations.borderColor,
|
|
196
|
+
borderStyle: this.#settings.decorations.borderStyle,
|
|
197
|
+
},
|
|
165
198
|
},
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
199
|
+
{ triggerTurn: true },
|
|
200
|
+
);
|
|
201
|
+
return { action: "handled" };
|
|
202
|
+
} catch {
|
|
203
|
+
ctx.ui.notify("Failed to submit prompt", "error");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
170
206
|
}
|
|
171
207
|
};
|
|
172
208
|
|
|
173
209
|
#onAgentStart = async (_e: AgentStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
174
210
|
this.#currentCtx = ctx;
|
|
211
|
+
this.cancelResponseModelFade(ctx);
|
|
175
212
|
if (!this.usable(ctx)) return;
|
|
176
213
|
if (!this.#state.startTime) this.resetTurn(Date.now());
|
|
177
214
|
this.#state.busy = true;
|
|
@@ -184,6 +221,7 @@ export class SessionManager {
|
|
|
184
221
|
if (this.usable(ctx) && event.message.role === "assistant") {
|
|
185
222
|
this.#state.liveTokens = 0;
|
|
186
223
|
this.#counter.reset();
|
|
224
|
+
this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
|
|
187
225
|
}
|
|
188
226
|
};
|
|
189
227
|
|
|
@@ -197,13 +235,20 @@ export class SessionManager {
|
|
|
197
235
|
) {
|
|
198
236
|
this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
|
|
199
237
|
}
|
|
238
|
+
if (this.usable(ctx)) {
|
|
239
|
+
const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
|
|
240
|
+
?? (event.message as { responseModel?: unknown } | undefined)?.responseModel;
|
|
241
|
+
this.updateResponseModel(rawResponseModel);
|
|
242
|
+
}
|
|
200
243
|
};
|
|
201
244
|
|
|
202
245
|
#onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
|
|
203
246
|
this.#currentCtx = ctx;
|
|
204
247
|
if (!this.usable(ctx) || event.message.role !== "assistant") return;
|
|
205
248
|
|
|
206
|
-
const
|
|
249
|
+
const rawTokens = event.message.usage?.output;
|
|
250
|
+
const exactTokens =
|
|
251
|
+
typeof rawTokens === "number" && Number.isFinite(rawTokens) && rawTokens >= 0 ? rawTokens : undefined;
|
|
207
252
|
this.#state.confirmTokens += exactTokens ?? this.#state.liveTokens;
|
|
208
253
|
if (exactTokens !== undefined) {
|
|
209
254
|
// Only reset the sampling state here, not tokenRateText/tokenRateFadeStartsAt:
|
|
@@ -214,6 +259,7 @@ export class SessionManager {
|
|
|
214
259
|
}
|
|
215
260
|
this.#state.liveTokens = 0;
|
|
216
261
|
this.#counter.reset();
|
|
262
|
+
this.updateResponseModel((event.message as { responseModel?: unknown }).responseModel);
|
|
217
263
|
};
|
|
218
264
|
|
|
219
265
|
#onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
@@ -225,18 +271,40 @@ export class SessionManager {
|
|
|
225
271
|
}
|
|
226
272
|
};
|
|
227
273
|
|
|
274
|
+
#onUIPromptStart = (event: UIPromptStartEvent, ctx: ExtensionContext): void => {
|
|
275
|
+
this.#currentCtx = ctx;
|
|
276
|
+
if (!this.usable(ctx) || !this.#state.busy) return;
|
|
277
|
+
this.#state.waiting = { kind: event.kind, title: event.title };
|
|
278
|
+
this.applyWaitingIndicator(ctx);
|
|
279
|
+
this.tick();
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
#onUIPromptEnd = (_event: UIPromptEndEvent, ctx: ExtensionContext): void => {
|
|
283
|
+
this.#currentCtx = ctx;
|
|
284
|
+
if (!this.usable(ctx) || !this.#state.waiting) return;
|
|
285
|
+
this.#state.waiting = null;
|
|
286
|
+
if (!this.#state.busy) return;
|
|
287
|
+
this.applyIndicator(ctx);
|
|
288
|
+
this.tick();
|
|
289
|
+
};
|
|
290
|
+
|
|
228
291
|
#onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
|
|
229
292
|
this.#currentCtx = ctx;
|
|
230
293
|
if (!this.usable(ctx)) return;
|
|
231
294
|
|
|
232
295
|
const hadPrompt = !!this.#state.startTime;
|
|
233
296
|
const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
|
|
297
|
+
const responseModel = this.#settings.features.responseModel ? this.#state.responseModel : "";
|
|
298
|
+
const responseModelColor = this.#settings.decorations.responseModelColor;
|
|
299
|
+
const responseModelDimmed = this.#settings.decorations.responseModelDimmed;
|
|
234
300
|
this.#state.busy = false;
|
|
301
|
+
this.#state.waiting = null;
|
|
235
302
|
this.#state.startTime = 0;
|
|
236
303
|
this.#counter.reset();
|
|
237
304
|
this.stopTimer();
|
|
238
|
-
ctx.ui.setWorkingMessage();
|
|
239
305
|
this.applyIndicator(ctx);
|
|
306
|
+
ctx.ui.setWorkingMessage();
|
|
307
|
+
if (responseModel) this.startResponseModelFade(ctx, responseModel, responseModelColor, responseModelDimmed);
|
|
240
308
|
this.#state.activityMeter.reset();
|
|
241
309
|
resetTokenRateState(this.#state);
|
|
242
310
|
this.#state.lastMessage = NOT_SENT;
|
|
@@ -251,7 +319,9 @@ export class SessionManager {
|
|
|
251
319
|
this.#currentCtx = null;
|
|
252
320
|
};
|
|
253
321
|
|
|
254
|
-
#onSessionShutdown = async (_e: SessionShutdownEvent,
|
|
322
|
+
#onSessionShutdown = async (_e: SessionShutdownEvent, ctx: ExtensionContext): Promise<void> => {
|
|
323
|
+
this.#state.waiting = null;
|
|
324
|
+
this.cancelResponseModelFade(ctx);
|
|
255
325
|
this.stopTimer();
|
|
256
326
|
};
|
|
257
327
|
|
|
@@ -298,6 +368,8 @@ export class SessionManager {
|
|
|
298
368
|
this.#pi.on("message_end", this.#onMessageEnd);
|
|
299
369
|
this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
|
|
300
370
|
this.#pi.on("agent_settled", this.#onAgentSettled);
|
|
371
|
+
this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
|
|
372
|
+
this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
|
|
301
373
|
this.#pi.on("session_shutdown", this.#onSessionShutdown);
|
|
302
374
|
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
|
|
303
375
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
@@ -345,6 +417,13 @@ export class SessionManager {
|
|
|
345
417
|
return `${decorations.animatedSpinner}:${decorations.spinnerColor}:${decorations.spinnerColorEnabled}:${this.#settings.loaderOrder[0]}`;
|
|
346
418
|
}
|
|
347
419
|
|
|
420
|
+
private applyWaitingIndicator(ctx: ExtensionContext): void {
|
|
421
|
+
ctx.ui.setWorkingIndicator({
|
|
422
|
+
frames: WAITING_PULSE_FRAMES.map((frame) => ctx.ui.theme.fg("dim", frame)),
|
|
423
|
+
intervalMs: WAITING_PULSE_INTERVAL_MS,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
348
427
|
private applyIndicator(ctx: ExtensionContext): void {
|
|
349
428
|
if (!this.#settings.decorations.animatedSpinner || this.spinnerInMessage()) {
|
|
350
429
|
ctx.ui.setWorkingIndicator({ frames: [] });
|
|
@@ -366,6 +445,58 @@ export class SessionManager {
|
|
|
366
445
|
}
|
|
367
446
|
}
|
|
368
447
|
|
|
448
|
+
private updateResponseModel(raw: unknown): void {
|
|
449
|
+
const responseModel = typeof raw === "string" ? stripControlChars(raw).trim() : "";
|
|
450
|
+
if (responseModel && responseModel !== this.#state.responseModel) {
|
|
451
|
+
this.#state.responseModel = responseModel;
|
|
452
|
+
this.tick();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
private cancelResponseModelFade(ctx?: ExtensionContext | null): void {
|
|
457
|
+
const state = this.#state;
|
|
458
|
+
state.responseModelFadeGeneration++;
|
|
459
|
+
if (state.responseModelHoldTimer) {
|
|
460
|
+
clearTimeout(state.responseModelHoldTimer);
|
|
461
|
+
state.responseModelHoldTimer = null;
|
|
462
|
+
}
|
|
463
|
+
if (state.responseModelFadeTimer) {
|
|
464
|
+
clearInterval(state.responseModelFadeTimer);
|
|
465
|
+
state.responseModelFadeTimer = null;
|
|
466
|
+
}
|
|
467
|
+
const activeCtx = ctx ?? this.#currentCtx;
|
|
468
|
+
if (activeCtx && this.usable(activeCtx)) {
|
|
469
|
+
activeCtx.ui.setStatus(RESPONSE_MODEL_STATUS_KEY, undefined);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private startResponseModelFade(ctx: ExtensionContext, model: string, color: SettingColor, dimmed: boolean): void {
|
|
474
|
+
this.cancelResponseModelFade(ctx);
|
|
475
|
+
const generation = this.#state.responseModelFadeGeneration;
|
|
476
|
+
const render = (shade?: number): void => {
|
|
477
|
+
const colored = shade === undefined
|
|
478
|
+
? ctx.ui.theme.fg(color, model)
|
|
479
|
+
: fadeThemeColorString(model, shade, ctx.ui.theme, color);
|
|
480
|
+
const responseModel = dimmed ? dimAttribute(colored) : colored;
|
|
481
|
+
ctx.ui.setStatus(RESPONSE_MODEL_STATUS_KEY, responseModel);
|
|
482
|
+
};
|
|
483
|
+
render();
|
|
484
|
+
this.#state.responseModelHoldTimer = setTimeout(() => {
|
|
485
|
+
if (this.#state.responseModelFadeGeneration !== generation) return;
|
|
486
|
+
this.#state.responseModelHoldTimer = null;
|
|
487
|
+
let shade = 0;
|
|
488
|
+
render(shade++);
|
|
489
|
+
this.#state.responseModelFadeTimer = setInterval(() => {
|
|
490
|
+
if (this.#state.responseModelFadeGeneration !== generation) return;
|
|
491
|
+
if (shade >= TOKEN_RATE_FADE_SHADE_COUNT) {
|
|
492
|
+
this.cancelResponseModelFade(ctx);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
render(shade++);
|
|
496
|
+
}, RESPONSE_MODEL_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT);
|
|
497
|
+
}, RESPONSE_MODEL_HOLD_MS);
|
|
498
|
+
}
|
|
499
|
+
|
|
369
500
|
private startTimer(): void {
|
|
370
501
|
if (this.#state.timer) return;
|
|
371
502
|
|
|
@@ -384,12 +515,14 @@ export class SessionManager {
|
|
|
384
515
|
}
|
|
385
516
|
|
|
386
517
|
private resetTurn(now: number): void {
|
|
518
|
+
this.cancelResponseModelFade();
|
|
387
519
|
const state = this.#state;
|
|
388
520
|
state.startTime = now;
|
|
389
521
|
state.shimmerOrigin = now;
|
|
390
522
|
state.workingText = this.pickWorkingWord();
|
|
391
523
|
state.confirmTokens = 0;
|
|
392
524
|
state.liveTokens = 0;
|
|
525
|
+
state.responseModel = "";
|
|
393
526
|
state.activityMeter.reset();
|
|
394
527
|
resetTokenRateState(state);
|
|
395
528
|
state.midTurnInputs = 0;
|
|
@@ -401,6 +534,14 @@ export class SessionManager {
|
|
|
401
534
|
const ctx = this.#currentCtx;
|
|
402
535
|
const state = this.#state;
|
|
403
536
|
if (!state.busy || !ctx) return;
|
|
537
|
+
if (state.waiting) {
|
|
538
|
+
const msg = ctx.ui.theme.fg("dim", waitingLabel(state.waiting));
|
|
539
|
+
if (msg !== state.lastMessage) {
|
|
540
|
+
state.lastMessage = msg;
|
|
541
|
+
ctx.ui.setWorkingMessage(msg);
|
|
542
|
+
}
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
404
545
|
|
|
405
546
|
const now = Date.now();
|
|
406
547
|
const total = state.confirmTokens + state.liveTokens;
|
|
@@ -418,9 +559,13 @@ export class SessionManager {
|
|
|
418
559
|
const spinner = this.spinnerInMessage()
|
|
419
560
|
? ctx.ui.theme.fg(this.spinnerColor(), SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
|
|
420
561
|
: "";
|
|
562
|
+
const responseModelColored = features.responseModel && state.responseModel
|
|
563
|
+
? ctx.ui.theme.fg(decorations.responseModelColor, state.responseModel)
|
|
564
|
+
: "";
|
|
565
|
+
const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
|
|
421
566
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
422
|
-
const msg = spinner
|
|
423
|
-
? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD) }, this.#settings.loaderOrder)
|
|
567
|
+
const msg = spinner || responseModel
|
|
568
|
+
? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, this.#settings.loaderOrder)
|
|
424
569
|
: undefined;
|
|
425
570
|
if (msg !== state.lastMessage) {
|
|
426
571
|
state.lastMessage = msg;
|
|
@@ -477,6 +622,7 @@ export class SessionManager {
|
|
|
477
622
|
elapsed: features.elapsedTime ? formatElapsed(now - state.startTime) : "",
|
|
478
623
|
tokens: features.outputTokens ? `↓ ${formatTokens(total)} tokens` : "",
|
|
479
624
|
tokenRate: tokenRateStyled,
|
|
625
|
+
responseModel,
|
|
480
626
|
},
|
|
481
627
|
this.#settings.loaderOrder,
|
|
482
628
|
);
|
|
@@ -497,8 +643,9 @@ export class SessionManager {
|
|
|
497
643
|
const preview = new PreviewRenderer(ctx, [...this.#bundledPacks, ...this.#userPacks]);
|
|
498
644
|
const result = await showMenu<Record<string, boolean | string>>(ctx, {
|
|
499
645
|
title: "Pi Topping: Settings",
|
|
646
|
+
maxHeight: "75%",
|
|
500
647
|
sections: buildMenuSections(this.#settings, this.#bundledPacks, this.#userPacks),
|
|
501
|
-
hints: ["↑↓ move", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
|
|
648
|
+
hints: ["↑↓ move", "PgUp/PgDn page", "←→ select", "␣ toggle", "⏎ apply", "esc cancel"],
|
|
502
649
|
preview: preview.render.bind(preview),
|
|
503
650
|
});
|
|
504
651
|
if (!result.applied) return;
|
|
@@ -512,7 +659,10 @@ export class SessionManager {
|
|
|
512
659
|
}
|
|
513
660
|
|
|
514
661
|
this.#settings = updatedSettings;
|
|
515
|
-
if (before !== this.indicatorFingerprint())
|
|
662
|
+
if (before !== this.indicatorFingerprint()) {
|
|
663
|
+
if (this.#state.waiting) this.applyWaitingIndicator(ctx);
|
|
664
|
+
else this.applyIndicator(ctx);
|
|
665
|
+
}
|
|
516
666
|
this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
|
|
517
667
|
if (this.#state.busy) {
|
|
518
668
|
this.stopTimer();
|
package/src/settings.ts
CHANGED
|
@@ -59,6 +59,8 @@ export interface DecoratorSettings {
|
|
|
59
59
|
meterDimmed: boolean;
|
|
60
60
|
tokenRateColor: SettingColor;
|
|
61
61
|
tokenRateDimmed: boolean;
|
|
62
|
+
responseModelColor: SettingColor;
|
|
63
|
+
responseModelDimmed: boolean;
|
|
62
64
|
promptIcon: boolean;
|
|
63
65
|
promptTimestamp: boolean;
|
|
64
66
|
promptProvider: boolean;
|
|
@@ -70,6 +72,7 @@ export interface DecoratorSettings {
|
|
|
70
72
|
elapsedTime: boolean;
|
|
71
73
|
outputTokens: boolean;
|
|
72
74
|
tokenRate: boolean;
|
|
75
|
+
responseModel: boolean;
|
|
73
76
|
doneMarker: boolean;
|
|
74
77
|
doneMarkerIcon: boolean;
|
|
75
78
|
randomizeDoneMarker: boolean;
|
|
@@ -81,8 +84,8 @@ export interface DecoratorSettings {
|
|
|
81
84
|
}
|
|
82
85
|
|
|
83
86
|
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
84
|
-
decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", doneMarkerStyle: "elite", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
|
|
85
|
-
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
|
|
87
|
+
decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "borderAccent", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "borderAccent", doneMarkerStyle: "elite", spinnerColor: "accent", spinnerColorEnabled: true, meterColor: "accent", meterColorEnabled: true, meterDimmed: false, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
|
|
88
|
+
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
|
|
86
89
|
loaderOrder: [...DEFAULT_LOADER_ORDER],
|
|
87
90
|
wordPacks: {},
|
|
88
91
|
};
|
|
@@ -96,6 +99,7 @@ const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
|
|
|
96
99
|
elapsed: "Elapsed time",
|
|
97
100
|
tokens: "Output tokens",
|
|
98
101
|
tokenRate: "Token rate",
|
|
102
|
+
responseModel: "Response model",
|
|
99
103
|
};
|
|
100
104
|
|
|
101
105
|
/**
|
|
@@ -198,6 +202,10 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
|
198
202
|
{ id: "showTokenRate", label: "Token rate", section: "“Working” Loader", group: "features", key: "tokenRate" },
|
|
199
203
|
{ id: "tokenRateColor", label: "Token rate color", section: "“Working” Loader", group: "decorations", key: "tokenRateColor", cycleValues: SETTING_COLOR_VALUES },
|
|
200
204
|
{ id: "tokenRateDimmed", label: "Token rate dimmed", section: "“Working” Loader", group: "decorations", key: "tokenRateDimmed" },
|
|
205
|
+
// id differs from key: the Elements Order row already owns "responseModel" in the menu's shared value namespace.
|
|
206
|
+
{ id: "showResponseModel", label: "Response model", section: "“Working” Loader", group: "features", key: "responseModel" },
|
|
207
|
+
{ id: "responseModelColor", label: "Response model color", section: "“Working” Loader", group: "decorations", key: "responseModelColor", cycleValues: SETTING_COLOR_VALUES },
|
|
208
|
+
{ id: "responseModelDimmed", label: "Response model dimmed", section: "“Working” Loader", group: "decorations", key: "responseModelDimmed" },
|
|
201
209
|
{ id: "doneMarker", label: "Show completion marker", section: "Completion Marker", group: "features", key: "doneMarker" },
|
|
202
210
|
{ id: "doneMarkerStyle", label: "Marker style", section: "Completion Marker", group: "decorations", key: "doneMarkerStyle", cycleValues: DONE_MARKER_STYLE_VALUES },
|
|
203
211
|
{ id: "doneMarkerBorderStyle", label: "Border style", section: "Completion Marker", group: "decorations", key: "doneMarkerBorderStyle", cycleValues: DONE_MARKER_BORDER_STYLE_VALUES },
|
|
@@ -228,6 +236,7 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
|
|
|
228
236
|
case "spinnerColor":
|
|
229
237
|
case "meterColor":
|
|
230
238
|
case "tokenRateColor":
|
|
239
|
+
case "responseModelColor":
|
|
231
240
|
case "doneMarkerBorderColor":
|
|
232
241
|
if (isSettingColor(stored)) decorations[key] = stored;
|
|
233
242
|
return;
|
package/src/setup-command.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function registerSetupCommand(pi: ExtensionAPI, install: ToppingInstaller
|
|
|
114
114
|
})),
|
|
115
115
|
},
|
|
116
116
|
],
|
|
117
|
-
hints: ["↑↓ move", "␣ toggle", "⏎ install", "esc cancel"],
|
|
117
|
+
hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ install", "esc cancel"],
|
|
118
118
|
previewTitle: "Description",
|
|
119
119
|
preview: (_values, _elapsedMs, activeItemId) => ({ lines: renderDescription(missing, activeItemId) }),
|
|
120
120
|
});
|