@underactive/pi-topping 0.8.0 → 0.8.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 +7 -4
- package/package.json +2 -2
- package/src/format.ts +21 -2
- package/src/loader-message.ts +58 -0
- package/src/menu.ts +29 -11
- package/src/nvidia-green.ts +2 -2
- package/src/preview.ts +39 -73
- package/src/session.ts +80 -66
- package/src/settings.ts +37 -28
- package/src/statusline-embed.ts +12 -0
- package/src/word-packs.ts +17 -8
package/README.md
CHANGED
|
@@ -100,17 +100,20 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
100
100
|
║ [ ] Star Trek OFF ║
|
|
101
101
|
║ [ ] Star Wars OFF ║
|
|
102
102
|
║ ║
|
|
103
|
+
║ [■] Include default/random “Working” text ON ║
|
|
104
|
+
║ ║
|
|
103
105
|
╟─ Options ─────────────────────────────────────────────────────╢
|
|
104
106
|
║ [■] Use NerdFont icons ON ║
|
|
107
|
+
║ [ ] Response model footer OFF ║
|
|
105
108
|
╟───────────────────────────────────────────────────────────────╢
|
|
106
109
|
║ ↑↓ move PgUp/PgDn page ←→ select ║
|
|
107
110
|
║ ␣ toggle ⏎ apply esc cancel ║
|
|
108
|
-
╚═══════════════════════════════════════════════════════[ 9/
|
|
111
|
+
╚═══════════════════════════════════════════════════════[ 9/56 ]╝
|
|
109
112
|
```
|
|
110
113
|
|
|
111
114
|
### Word packs
|
|
112
115
|
|
|
113
|
-
Base activity words are
|
|
116
|
+
Base activity words are included by default. All word packs, including the shipped **Doctor Who**, **Firefly**, **Hitchhiker's Guide**, **The Lord of the Rings**, **The Matrix**, **Portal**, **SimCity**, **Star Trek**, and **Star Wars** packs and custom packs, are disabled by default. Enable packs in `/topping-settings` to add their entries to the same uniformly selected pool. With at least one pack enabled, turn off **Include default/random “Working” text** to draw only from enabled packs; with none enabled the option is locked on. When Randomize “Worked” text is on, the completion marker uses the selected entry’s matching past tense; otherwise it uses `Worked`.
|
|
114
117
|
|
|
115
118
|
Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.json`, `wordpacks/firefly.json`, `wordpacks/hitchhikers-guide.json`, `wordpacks/lord-of-the-rings.json`, `wordpacks/matrix.json`, `wordpacks/portal.json`, `wordpacks/simcity.json`, `wordpacks/star-trek.json`, or `wordpacks/star-wars.json` to `~/.pi/agent/pi-topping/word-packs.json`, then change the pack `id` (the bundled `doctor-who`, `firefly`, `hitchhikers-guide`, `lord-of-the-rings`, `matrix`, `portal`, `simcity`, `star-trek`, and `star-wars` IDs are reserved), name, and words:
|
|
116
119
|
|
|
@@ -141,7 +144,7 @@ full brightness for 1.5 seconds, then fades through five theme-aware shades to t
|
|
|
141
144
|
the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
|
|
142
145
|
restarts the cycle.
|
|
143
146
|
|
|
144
|
-
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.
|
|
147
|
+
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. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
|
|
145
148
|
|
|
146
149
|
## Requirements
|
|
147
150
|
|
|
@@ -157,7 +160,7 @@ npm test
|
|
|
157
160
|
npm run typecheck
|
|
158
161
|
```
|
|
159
162
|
|
|
160
|
-
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
|
|
163
|
+
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, loading, and default-text selection, response-model loader and footer behavior, the `menu.ts` component, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
|
|
161
164
|
|
|
162
165
|
## Limitations
|
|
163
166
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"bugs": {
|
|
13
13
|
"url": "https://github.com/underactive/pi-topping/issues"
|
|
14
14
|
},
|
|
15
|
-
"homepage": "https://
|
|
15
|
+
"homepage": "https://esison.dev/pi-topping",
|
|
16
16
|
"type": "module",
|
|
17
17
|
"engines": {
|
|
18
18
|
"node": ">=22.19.0"
|
package/src/format.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import type { ThinkingLevelColor
|
|
2
|
+
import type { ThinkingLevelColor } from "./settings.ts";
|
|
3
3
|
|
|
4
4
|
/** Pi's default working-indicator frames (same braille spinner as pi-tui's Loader). */
|
|
5
5
|
export const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -21,7 +21,7 @@ export function isThinkingLevel(value: unknown): value is ThinkingLevel {
|
|
|
21
21
|
/** Build a colorizer for a configured color, including Pi's active thinking-level color. */
|
|
22
22
|
export function getThinkingLevelColorizer(
|
|
23
23
|
theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
|
|
24
|
-
color: ThinkingLevelColor
|
|
24
|
+
color: ThinkingLevelColor,
|
|
25
25
|
thinkingLevel: ThinkingLevel | undefined,
|
|
26
26
|
): (text: string) => string {
|
|
27
27
|
return color === "thinking-level"
|
|
@@ -105,6 +105,25 @@ export const RESPONSE_MODEL_HOLD_MS = 3_000;
|
|
|
105
105
|
/** Milliseconds over which a settled response model fades through the theme shades. */
|
|
106
106
|
export const RESPONSE_MODEL_FADE_MS = 500;
|
|
107
107
|
|
|
108
|
+
/** Milliseconds over which a newly shown response model slides out of the working loader. */
|
|
109
|
+
export const RESPONSE_MODEL_SLIDE_MS = 300;
|
|
110
|
+
|
|
111
|
+
/** Milliseconds between working-loader repaints while the response model slides out. */
|
|
112
|
+
export const RESPONSE_MODEL_SLIDE_FRAME_MS = 30;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The part of a sliding response model visible `elapsedMs` after it appeared. It emerges
|
|
116
|
+
* tail first from behind the separator before it, as pi-topping-statusline slides an
|
|
117
|
+
* embedded status out, and shows whole once RESPONSE_MODEL_SLIDE_MS has passed.
|
|
118
|
+
*/
|
|
119
|
+
export function slideOutTail(text: string, elapsedMs: number): string {
|
|
120
|
+
if (elapsedMs >= RESPONSE_MODEL_SLIDE_MS) return text;
|
|
121
|
+
const chars = [...text];
|
|
122
|
+
const progress = Math.max(0, elapsedMs) / RESPONSE_MODEL_SLIDE_MS;
|
|
123
|
+
const shown = Math.round(chars.length * 0.5 * (1 - Math.cos(Math.PI * progress)));
|
|
124
|
+
return chars.slice(chars.length - shown).join("");
|
|
125
|
+
}
|
|
126
|
+
|
|
108
127
|
/** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
|
|
109
128
|
export function fadeThemeColorString(
|
|
110
129
|
text: string,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, fadeThemeColorString, formatElapsed, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES, TOKEN_RATE_PLACEHOLDER, type LoaderElement, type ThinkingLevel } from "./format.ts";
|
|
3
|
+
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
4
|
+
import type { DecoratorSettings } from "./settings.ts";
|
|
5
|
+
|
|
6
|
+
type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate" | "responseModel">;
|
|
7
|
+
type Decorations = Pick<DecoratorSettings["decorations"], "shimmer" | "shimmerInverted" | "shimmerDirection" | "shimmerSpeed" | "tokenActivityMonitor" | "meterColor" | "meterDimmed" | "tokenRateColor" | "tokenRateDimmed" | "responseModelColor" | "responseModelDimmed" | "spinnerColor">;
|
|
8
|
+
interface LoaderParts {
|
|
9
|
+
spinnerAtMs?: number;
|
|
10
|
+
word: string;
|
|
11
|
+
shimmerAtMs?: number;
|
|
12
|
+
renderMeter?: (colorizer: (text: string) => string, dimmed: boolean) => string;
|
|
13
|
+
elapsedMs?: number;
|
|
14
|
+
outputTokens?: number;
|
|
15
|
+
tokenRateText?: string;
|
|
16
|
+
tokenRateShade?: number;
|
|
17
|
+
responseModel?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Assemble the live and preview loaders with identical styling and ordering. */
|
|
21
|
+
export function buildLoaderMessage(
|
|
22
|
+
theme: Theme,
|
|
23
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
24
|
+
provider: string | undefined,
|
|
25
|
+
features: Features,
|
|
26
|
+
decorations: Decorations,
|
|
27
|
+
loaderOrder: readonly LoaderElement[],
|
|
28
|
+
parts: LoaderParts,
|
|
29
|
+
): string {
|
|
30
|
+
const spinner = parts.spinnerAtMs === undefined ? "" : getThinkingLevelColorizer(theme, decorations.spinnerColor, thinkingLevel)(SPINNER_FRAMES[Math.floor(parts.spinnerAtMs / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!);
|
|
31
|
+
const responseModelColored = parts.responseModel ? getResponseModelColorizer(theme, decorations.responseModelColor, thinkingLevel, provider)(parts.responseModel) : "";
|
|
32
|
+
const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
|
|
33
|
+
if (isFullyDefaultAppearance(features, decorations)) {
|
|
34
|
+
return buildWorkingMessage(theme, { spinner, text: theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, loaderOrder);
|
|
35
|
+
}
|
|
36
|
+
const word = features.substituteDefaultMessage ? parts.word : DEFAULT_WORKING_WORD;
|
|
37
|
+
const text = decorations.shimmer && parts.shimmerAtMs !== undefined
|
|
38
|
+
? shimmerString(word, parts.shimmerAtMs, theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
|
|
39
|
+
: theme.fg("text", word);
|
|
40
|
+
const meter = decorations.tokenActivityMonitor && parts.renderMeter
|
|
41
|
+
? parts.renderMeter(getThinkingLevelColorizer(theme, decorations.meterColor, thinkingLevel), decorations.meterDimmed)
|
|
42
|
+
: "";
|
|
43
|
+
let tokenRate = "";
|
|
44
|
+
if (features.tokenRate) {
|
|
45
|
+
if (!parts.tokenRateText) tokenRate = theme.fg("dim", TOKEN_RATE_PLACEHOLDER);
|
|
46
|
+
else {
|
|
47
|
+
const colorizer = getThinkingLevelColorizer(theme, decorations.tokenRateColor, thinkingLevel);
|
|
48
|
+
tokenRate = parts.tokenRateShade === undefined ? colorizer(parts.tokenRateText) : fadeThemeColorString(parts.tokenRateText, parts.tokenRateShade, theme, colorizer);
|
|
49
|
+
}
|
|
50
|
+
if (tokenRate && decorations.tokenRateDimmed) tokenRate = dimAttribute(tokenRate);
|
|
51
|
+
}
|
|
52
|
+
return buildWorkingMessage(theme, {
|
|
53
|
+
spinner, text, meter,
|
|
54
|
+
elapsed: features.elapsedTime && parts.elapsedMs !== undefined ? formatElapsed(parts.elapsedMs) : "",
|
|
55
|
+
tokens: features.outputTokens && parts.outputTokens !== undefined ? `↓ ${formatTokens(parts.outputTokens)} tokens` : "",
|
|
56
|
+
tokenRate, responseModel,
|
|
57
|
+
}, loaderOrder);
|
|
58
|
+
}
|
package/src/menu.ts
CHANGED
|
@@ -26,6 +26,10 @@ export interface MenuItem {
|
|
|
26
26
|
id: string;
|
|
27
27
|
label: string;
|
|
28
28
|
value: MenuValue;
|
|
29
|
+
/** Render a blank, non-selectable row immediately before this item. */
|
|
30
|
+
spacerBefore?: boolean;
|
|
31
|
+
/** IDs for which at least one value must be true before this item can be toggled. */
|
|
32
|
+
disabledUnlessAnyOf?: readonly string[];
|
|
29
33
|
/** Values cycled with left/right arrows. Omit for a boolean space-toggle. */
|
|
30
34
|
cycleValues?: readonly string[];
|
|
31
35
|
/** Optional display labels for cycle values; the keys remain the published values. */
|
|
@@ -215,6 +219,7 @@ export class MenuComponent implements Component {
|
|
|
215
219
|
[Key.right]: () => this.cycleCurrentValue(1),
|
|
216
220
|
[Key.space]: () => {
|
|
217
221
|
const item = this.flat[this.cursor]!.item;
|
|
222
|
+
if (this.isDisabled(item)) return;
|
|
218
223
|
if (item.cycleValues && item.cycleEnabledBy) {
|
|
219
224
|
this.values[item.cycleEnabledBy] = !this.values[item.cycleEnabledBy] as boolean;
|
|
220
225
|
if (!this.values[item.cycleEnabledBy] && item.cycleDisabledValue !== undefined) this.values[item.id] = item.cycleDisabledValue;
|
|
@@ -286,9 +291,14 @@ export class MenuComponent implements Component {
|
|
|
286
291
|
return Math.max(1, rows - 6);
|
|
287
292
|
}
|
|
288
293
|
|
|
294
|
+
private isDisabled(item: MenuItem): boolean {
|
|
295
|
+
const requiredIds = item.disabledUnlessAnyOf;
|
|
296
|
+
return requiredIds !== undefined && !requiredIds.some(id => this.values[id] === true);
|
|
297
|
+
}
|
|
298
|
+
|
|
289
299
|
private cycleCurrentValue(delta: number): void {
|
|
290
300
|
const item = this.flat[this.cursor]!.item;
|
|
291
|
-
if (!item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
301
|
+
if (this.isDisabled(item) || !item.cycleValues?.length || (item.cycleEnabledBy && !this.values[item.cycleEnabledBy])) return;
|
|
292
302
|
const current = item.cycleValues.indexOf(this.values[item.id] as string);
|
|
293
303
|
const index = (current + delta + item.cycleValues.length) % item.cycleValues.length;
|
|
294
304
|
this.values[item.id] = item.cycleValues[index]!;
|
|
@@ -338,6 +348,7 @@ export class MenuComponent implements Component {
|
|
|
338
348
|
lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
|
|
339
349
|
currentSection = flat.sectionIndex;
|
|
340
350
|
}
|
|
351
|
+
if (flat.item.spacerBefore) lines.push(this.renderBlankRow(innerWidth));
|
|
341
352
|
lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
|
|
342
353
|
}
|
|
343
354
|
if (lines.length) lines.push(this.renderBlankRow(innerWidth));
|
|
@@ -363,6 +374,7 @@ export class MenuComponent implements Component {
|
|
|
363
374
|
if (maxRows - lines.length >= 2) lines.push(this.renderSectionDivider(this.sections[flat.sectionIndex]!.title, innerWidth));
|
|
364
375
|
currentSection = flat.sectionIndex;
|
|
365
376
|
}
|
|
377
|
+
if (flat.item.spacerBefore && maxRows - lines.length >= 2) lines.push(this.renderBlankRow(innerWidth));
|
|
366
378
|
if (lines.length >= maxRows) break;
|
|
367
379
|
lines.push(this.renderItemRow(flat.item, index === this.cursor, innerWidth));
|
|
368
380
|
end = index;
|
|
@@ -388,18 +400,20 @@ export class MenuComponent implements Component {
|
|
|
388
400
|
|
|
389
401
|
// Walk backward from cursor to find the lowest scrollStart that fits in contentRows.
|
|
390
402
|
{
|
|
391
|
-
let rowsUsed = 2
|
|
403
|
+
let rowsUsed = 2 + (this.flat[this.cursor]!.item.spacerBefore ? 1 : 0); // cursor item + first-section divider + optional spacer
|
|
392
404
|
let section = this.flat[this.cursor]!.sectionIndex;
|
|
393
405
|
this.scrollStart = this.cursor;
|
|
394
406
|
for (let i = this.cursor - 1; i >= 0; i--) {
|
|
395
|
-
const
|
|
407
|
+
const previous = this.flat[i]!;
|
|
408
|
+
const sec = previous.sectionIndex;
|
|
409
|
+
const itemRows = 1 + (previous.item.spacerBefore ? 1 : 0);
|
|
396
410
|
if (sec !== section) {
|
|
397
|
-
if (rowsUsed +
|
|
398
|
-
rowsUsed +=
|
|
411
|
+
if (rowsUsed + itemRows + 2 > contentRows) break;
|
|
412
|
+
rowsUsed += itemRows + 2;
|
|
399
413
|
section = sec;
|
|
400
414
|
} else {
|
|
401
|
-
if (rowsUsed +
|
|
402
|
-
rowsUsed +=
|
|
415
|
+
if (rowsUsed + itemRows > contentRows) break;
|
|
416
|
+
rowsUsed += itemRows;
|
|
403
417
|
}
|
|
404
418
|
this.scrollStart = i;
|
|
405
419
|
}
|
|
@@ -438,7 +452,8 @@ export class MenuComponent implements Component {
|
|
|
438
452
|
const header = [this.renderTopBorder(innerWidth), ...this.buildPreviewBlock(previewLines, innerWidth)];
|
|
439
453
|
const footer = this.buildFooter(innerWidth);
|
|
440
454
|
const sectionsWithItems = new Set(this.flat.map(f => f.sectionIndex)).size;
|
|
441
|
-
const
|
|
455
|
+
const spacerRows = this.flat.filter(flat => flat.item.spacerBefore).length;
|
|
456
|
+
const naturalBodyLength = this.flat.length + spacerRows + 2 * sectionsWithItems;
|
|
442
457
|
if (maxRows === undefined || header.length + naturalBodyLength + footer.length <= maxRows) {
|
|
443
458
|
const naturalBody = this.buildToggleSections(innerWidth);
|
|
444
459
|
this.pageItemCount = Math.max(1, this.flat.length);
|
|
@@ -542,7 +557,7 @@ export class MenuComponent implements Component {
|
|
|
542
557
|
}
|
|
543
558
|
|
|
544
559
|
if (item.cycleValues) {
|
|
545
|
-
const enabled = item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true;
|
|
560
|
+
const enabled = !this.isDisabled(item) && (item.cycleEnabledBy ? this.values[item.cycleEnabledBy] as boolean : true);
|
|
546
561
|
const rawValue = typeof value === "string" ? value : String(value);
|
|
547
562
|
const displayValue = item.cycleValueLabels?.[rawValue] ?? rawValue;
|
|
548
563
|
const stateWord = `‹ ${displayValue} ›`;
|
|
@@ -554,7 +569,8 @@ export class MenuComponent implements Component {
|
|
|
554
569
|
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
555
570
|
}
|
|
556
571
|
|
|
557
|
-
const
|
|
572
|
+
const disabled = this.isDisabled(item);
|
|
573
|
+
const enabled = disabled || value as boolean;
|
|
558
574
|
const box = enabled ? "\u25a0" : " ";
|
|
559
575
|
const stateWord = enabled ? "ON" : "OFF";
|
|
560
576
|
const rightPlain = `${stateWord} `;
|
|
@@ -562,7 +578,9 @@ export class MenuComponent implements Component {
|
|
|
562
578
|
const label = visibleWidth(item.label) > maxLabelLen ? truncateToWidth(item.label, maxLabelLen) : item.label;
|
|
563
579
|
const leftPlain = ` ${marker} [${box}] ${label}`;
|
|
564
580
|
const gap = Math.max(1, innerWidth - visibleWidth(leftPlain) - visibleWidth(rightPlain));
|
|
565
|
-
const
|
|
581
|
+
const labelColor = disabled ? "muted" : "text";
|
|
582
|
+
const stateColor = disabled ? "muted" : enabled ? "success" : "muted";
|
|
583
|
+
const content = ` ${markerColored} [${th.fg(stateColor, box)}] ${th.fg(labelColor, label)}${" ".repeat(gap)}${th.fg(stateColor, stateWord)} `;
|
|
566
584
|
return this.wrap("\u2551", selected ? th.bg("selectedBg", content) : content, "\u2551");
|
|
567
585
|
}
|
|
568
586
|
}
|
package/src/nvidia-green.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getThinkingLevelColorizer, type ThinkingLevel } from "./format.ts";
|
|
3
|
-
import type { ThinkingLevelColor
|
|
3
|
+
import type { ThinkingLevelColor } from "./settings.ts";
|
|
4
4
|
|
|
5
5
|
/** Provider id whose working-loader response model uses NVIDIA green. */
|
|
6
6
|
export const SWITCHYARD_PROVIDER = "switchyard";
|
|
@@ -27,7 +27,7 @@ export function colorizeNvidiaGreen(text: string): string {
|
|
|
27
27
|
*/
|
|
28
28
|
export function getResponseModelColorizer(
|
|
29
29
|
theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
|
|
30
|
-
color: ThinkingLevelColor
|
|
30
|
+
color: ThinkingLevelColor,
|
|
31
31
|
thinkingLevel: ThinkingLevel | undefined,
|
|
32
32
|
provider: string | undefined,
|
|
33
33
|
): (text: string) => string {
|
package/src/preview.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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 {
|
|
4
|
+
import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRate, getThinkingLevelColorizer, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
|
|
5
|
+
import { buildLoaderMessage } from "./loader-message.ts";
|
|
5
6
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
6
7
|
import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
7
8
|
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
|
|
8
|
-
import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
|
|
9
|
+
import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, wordPacksPath, type WordPack } from "./word-packs.ts";
|
|
9
10
|
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
|
|
10
11
|
// flat 28 tps for the token readouts.
|
|
11
12
|
const METER_PERIOD_MS = 2400, METER_PEAK_RATE = 46, TOKEN_RATE_PER_SEC = 28;
|
|
@@ -31,7 +32,13 @@ export class PreviewRenderer {
|
|
|
31
32
|
if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
|
|
32
33
|
if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
|
|
33
34
|
if (activeItemId === "useNerdFont") return { lines: ["", `Icon preview: ${values.useNerdFont ? "" : "π"}`, ""] };
|
|
34
|
-
if (activeItemId
|
|
35
|
+
if (activeItemId === "showResponseModelFooter") {
|
|
36
|
+
if (values.showResponseModelFooter !== true) return { lines: ["", "Response model footer is disabled.", ""] };
|
|
37
|
+
const color = isThinkingLevelColor(values.responseModelColor) ? values.responseModelColor : DEFAULT_SETTINGS.decorations.responseModelColor;
|
|
38
|
+
const colored = getResponseModelColorizer(this.#ctx.ui.theme, color, this.#ctx.thinkingLevel, this.#ctx.model?.provider)("test-model");
|
|
39
|
+
return { lines: ["", values.responseModelDimmed === true ? dimAttribute(colored) : colored, ""] };
|
|
40
|
+
}
|
|
41
|
+
if (activeItemId?.startsWith(WORD_PACK_MENU_PREFIX)) return this.packPreview(activeItemId.slice(WORD_PACK_MENU_PREFIX.length), values);
|
|
35
42
|
let nextRefreshInMs: number | undefined;
|
|
36
43
|
if (values.shimmer !== false) {
|
|
37
44
|
nextRefreshInMs = SHIMMER_INTERVAL_MS;
|
|
@@ -51,88 +58,47 @@ export class PreviewRenderer {
|
|
|
51
58
|
this.#lastMeterUpdate = elapsedMs;
|
|
52
59
|
}
|
|
53
60
|
const features = { substituteDefaultMessage: values.substituteDefaultMessage !== false, elapsedTime: values.elapsedTime !== false, outputTokens: values.outputTokens !== false, tokenRate: values.showTokenRate !== false, responseModel: values.showResponseModel !== false };
|
|
54
|
-
const decorations = {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
|
|
69
|
-
if (isFullyDefaultAppearance(features, decorations)) {
|
|
70
|
-
return buildWorkingMessage(this.#ctx.ui.theme, { spinner, text: this.#ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, order);
|
|
71
|
-
}
|
|
61
|
+
const decorations = {
|
|
62
|
+
shimmer: values.shimmer !== false,
|
|
63
|
+
shimmerInverted: values.shimmerInverted === true,
|
|
64
|
+
shimmerDirection: fromCycleDirection(values.shimmerDirection),
|
|
65
|
+
shimmerSpeed: fromCycleSpeed(values.shimmerSpeed),
|
|
66
|
+
tokenActivityMonitor: values.tokenActivityMonitor !== false,
|
|
67
|
+
meterColor: isThinkingLevelColor(values.meterColor) ? values.meterColor : DEFAULT_SETTINGS.decorations.meterColor,
|
|
68
|
+
meterDimmed: values.meterDimmed === true,
|
|
69
|
+
tokenRateColor: isThinkingLevelColor(values.tokenRateColor) ? values.tokenRateColor : DEFAULT_SETTINGS.decorations.tokenRateColor,
|
|
70
|
+
tokenRateDimmed: values.tokenRateDimmed === true,
|
|
71
|
+
responseModelColor: isThinkingLevelColor(values.responseModelColor) ? values.responseModelColor : DEFAULT_SETTINGS.decorations.responseModelColor,
|
|
72
|
+
responseModelDimmed: values.responseModelDimmed === true,
|
|
73
|
+
spinnerColor: isSpinnerColor(values.spinnerColor) ? values.spinnerColor : DEFAULT_SETTINGS.decorations.spinnerColor,
|
|
74
|
+
};
|
|
72
75
|
let word = DEFAULT_WORKING_WORD;
|
|
73
76
|
if (features.substituteDefaultMessage) {
|
|
74
77
|
const packValues = this.packValues(values);
|
|
75
|
-
const
|
|
78
|
+
const includeDefaultWorkingText = values.includeDefaultWorkingText !== false;
|
|
79
|
+
const signature = JSON.stringify([packValues, includeDefaultWorkingText]);
|
|
76
80
|
if (signature !== this.#cachedPackSignature) {
|
|
77
|
-
this.#cachedPackWord = selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction).text;
|
|
81
|
+
this.#cachedPackWord = selectWorkingTextSelection(packValues, this.#packs, this.#poolFraction, includeDefaultWorkingText).text;
|
|
78
82
|
this.#cachedPackSignature = signature;
|
|
79
83
|
}
|
|
80
84
|
word = this.#cachedPackWord;
|
|
81
85
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (decorations.tokenActivityMonitor) {
|
|
94
|
-
const meterColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, meterColor, this.#ctx.thinkingLevel);
|
|
95
|
-
meter = this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, this.#ctx.ui.theme, meterColorizer, values.meterDimmed === true));
|
|
96
|
-
}
|
|
97
|
-
let tokenRateText = "";
|
|
98
|
-
if (features.tokenRate) {
|
|
99
|
-
tokenRateText = formatTokenRate(TOKEN_RATE_PER_SEC);
|
|
100
|
-
}
|
|
101
|
-
let tokenRateColor = DEFAULT_SETTINGS.decorations.tokenRateColor;
|
|
102
|
-
if (isThinkingLevelColor(values.tokenRateColor)) {
|
|
103
|
-
tokenRateColor = values.tokenRateColor;
|
|
104
|
-
}
|
|
105
|
-
let tokenRateColored = "";
|
|
106
|
-
if (tokenRateText) {
|
|
107
|
-
const tokenRateColorizer = getThinkingLevelColorizer(this.#ctx.ui.theme, tokenRateColor, this.#ctx.thinkingLevel);
|
|
108
|
-
tokenRateColored = tokenRateColorizer(tokenRateText);
|
|
109
|
-
}
|
|
110
|
-
let tokenRate = tokenRateColored;
|
|
111
|
-
if (tokenRateColored && values.tokenRateDimmed === true) {
|
|
112
|
-
tokenRate = dimAttribute(tokenRateColored);
|
|
113
|
-
}
|
|
114
|
-
let elapsed = "";
|
|
115
|
-
if (features.elapsedTime) {
|
|
116
|
-
elapsed = formatElapsed(elapsedMs);
|
|
117
|
-
}
|
|
118
|
-
let tokens = "";
|
|
119
|
-
if (features.outputTokens) {
|
|
120
|
-
tokens = `↓ ${formatTokens(Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)))} tokens`;
|
|
121
|
-
}
|
|
122
|
-
return buildWorkingMessage(this.#ctx.ui.theme, {
|
|
123
|
-
spinner,
|
|
124
|
-
text: styledWord,
|
|
125
|
-
meter,
|
|
126
|
-
elapsed,
|
|
127
|
-
tokens,
|
|
128
|
-
tokenRate,
|
|
129
|
-
responseModel,
|
|
130
|
-
}, order);
|
|
86
|
+
const theme = this.#ctx.ui.theme;
|
|
87
|
+
return buildLoaderMessage(theme, this.#ctx.thinkingLevel, this.#ctx.model?.provider, features, decorations, parseLoaderOrder(values[LOADER_ORDER_ID]), {
|
|
88
|
+
spinnerAtMs: values.animatedSpinner !== false ? elapsedMs : undefined,
|
|
89
|
+
word,
|
|
90
|
+
shimmerAtMs: elapsedMs,
|
|
91
|
+
renderMeter: (colorizer, dimmed) => this.#meter.render((level, char) => ActivityMeter.colorizeCell(level, char, theme, colorizer, dimmed)),
|
|
92
|
+
elapsedMs,
|
|
93
|
+
outputTokens: Math.max(0, Math.floor(elapsedMs / 1000 * TOKEN_RATE_PER_SEC)),
|
|
94
|
+
tokenRateText: formatTokenRate(TOKEN_RATE_PER_SEC),
|
|
95
|
+
responseModel: features.responseModel ? "test-model" : undefined,
|
|
96
|
+
});
|
|
131
97
|
}
|
|
132
98
|
private packValues(values: Record<string, boolean | string>): Record<string, boolean> {
|
|
133
99
|
const enabled: Record<string, boolean> = {};
|
|
134
100
|
for (const pack of this.#packs) {
|
|
135
|
-
const value = values[
|
|
101
|
+
const value = values[`${WORD_PACK_MENU_PREFIX}${pack.id}`];
|
|
136
102
|
if (typeof value === "boolean") enabled[pack.id] = value;
|
|
137
103
|
}
|
|
138
104
|
return enabled;
|
package/src/session.ts
CHANGED
|
@@ -15,8 +15,6 @@ import type {
|
|
|
15
15
|
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
16
16
|
import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
|
|
17
17
|
import {
|
|
18
|
-
buildWorkingMessage,
|
|
19
|
-
DEFAULT_WORKING_WORD,
|
|
20
18
|
dimAttribute,
|
|
21
19
|
getThinkingLevelColorizer,
|
|
22
20
|
isThinkingLevel,
|
|
@@ -29,19 +27,22 @@ import {
|
|
|
29
27
|
METER_INTERVAL_MS,
|
|
30
28
|
RESPONSE_MODEL_FADE_MS,
|
|
31
29
|
RESPONSE_MODEL_HOLD_MS,
|
|
30
|
+
RESPONSE_MODEL_SLIDE_FRAME_MS,
|
|
31
|
+
RESPONSE_MODEL_SLIDE_MS,
|
|
32
32
|
SHIMMER_INTERVAL_MS,
|
|
33
|
-
|
|
33
|
+
slideOutTail,
|
|
34
34
|
SPINNER_FRAME_MS,
|
|
35
35
|
SPINNER_FRAMES,
|
|
36
36
|
TOKEN_RATE_FADE_SHADE_COUNT,
|
|
37
|
-
TOKEN_RATE_PLACEHOLDER,
|
|
38
37
|
StreamingWordCounter,
|
|
39
38
|
type ThinkingLevel,
|
|
40
39
|
} from "./format.ts";
|
|
40
|
+
import { buildLoaderMessage } from "./loader-message.ts";
|
|
41
41
|
import { showMenu } from "./menu.ts";
|
|
42
42
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
43
43
|
import { registerSetupCommand } from "./setup-command.ts";
|
|
44
44
|
import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
|
|
45
|
+
import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.ts";
|
|
45
46
|
import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
|
|
46
47
|
import { PreviewRenderer } from "./preview.ts";
|
|
47
48
|
import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
|
|
@@ -96,6 +97,9 @@ interface SessionState {
|
|
|
96
97
|
responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
|
|
97
98
|
responseModelFadeTimer: ReturnType<typeof setInterval> | null;
|
|
98
99
|
responseModelFadeGeneration: number;
|
|
100
|
+
/** When the response model appeared in the loader and began sliding out; 0 if it never slid. */
|
|
101
|
+
responseModelShownAt: number;
|
|
102
|
+
responseModelSlideTimer: ReturnType<typeof setInterval> | null;
|
|
99
103
|
tokenRateText: string;
|
|
100
104
|
tokenRateFadeStartsAt: number;
|
|
101
105
|
timer: ReturnType<typeof setInterval> | null;
|
|
@@ -134,6 +138,8 @@ function makeFreshState(): SessionState {
|
|
|
134
138
|
responseModelHoldTimer: null,
|
|
135
139
|
responseModelFadeTimer: null,
|
|
136
140
|
responseModelFadeGeneration: 0,
|
|
141
|
+
responseModelShownAt: 0,
|
|
142
|
+
responseModelSlideTimer: null,
|
|
137
143
|
tokenRateText: "",
|
|
138
144
|
tokenRateFadeStartsAt: 0,
|
|
139
145
|
timer: null,
|
|
@@ -162,6 +168,8 @@ export class SessionManager {
|
|
|
162
168
|
#bundledPacks = loadBundledWordPacks();
|
|
163
169
|
#allPacks: WordPack[] = [...this.#bundledPacks];
|
|
164
170
|
#currentCtx: ExtensionContext | null = null;
|
|
171
|
+
/** Whether pi-topping-statusline last announced that its status bar hosts the loader; kept across sessions. */
|
|
172
|
+
#loaderInStatusline = false;
|
|
165
173
|
readonly #pi: ExtensionAPI;
|
|
166
174
|
|
|
167
175
|
constructor(pi: ExtensionAPI) {
|
|
@@ -171,6 +179,7 @@ export class SessionManager {
|
|
|
171
179
|
#onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
172
180
|
this.#currentCtx = ctx;
|
|
173
181
|
this.stopTimer();
|
|
182
|
+
this.stopResponseModelSlide();
|
|
174
183
|
this.cancelResponseModelFade(ctx);
|
|
175
184
|
this.#counter.reset();
|
|
176
185
|
this.#state = makeFreshState();
|
|
@@ -308,7 +317,7 @@ export class SessionManager {
|
|
|
308
317
|
|
|
309
318
|
const hadPrompt = !!this.#state.startTime;
|
|
310
319
|
const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
|
|
311
|
-
const responseModel = this.#settings.features.
|
|
320
|
+
const responseModel = this.#settings.features.responseModelFooter ? this.#state.responseModel : "";
|
|
312
321
|
const responseModelColor = this.#settings.decorations.responseModelColor;
|
|
313
322
|
const responseModelDimmed = this.#settings.decorations.responseModelDimmed;
|
|
314
323
|
this.#state.busy = false;
|
|
@@ -316,6 +325,7 @@ export class SessionManager {
|
|
|
316
325
|
this.#state.startTime = 0;
|
|
317
326
|
this.#counter.reset();
|
|
318
327
|
this.stopTimer();
|
|
328
|
+
this.stopResponseModelSlide();
|
|
319
329
|
this.applyIndicator(ctx);
|
|
320
330
|
ctx.ui.setWorkingMessage();
|
|
321
331
|
if (responseModel) this.startResponseModelFade(ctx, responseModel, responseModelColor, responseModelDimmed);
|
|
@@ -339,6 +349,7 @@ export class SessionManager {
|
|
|
339
349
|
this.#state.waiting = null;
|
|
340
350
|
this.cancelResponseModelFade(ctx);
|
|
341
351
|
this.stopTimer();
|
|
352
|
+
this.stopResponseModelSlide();
|
|
342
353
|
};
|
|
343
354
|
|
|
344
355
|
#renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
|
|
@@ -393,6 +404,9 @@ export class SessionManager {
|
|
|
393
404
|
this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
|
|
394
405
|
this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
|
|
395
406
|
this.#pi.on("session_shutdown", this.#onSessionShutdown);
|
|
407
|
+
this.#pi.events.on(STATUSLINE_EMBED_CHANNEL, (data) => {
|
|
408
|
+
this.#loaderInStatusline = announcesEmbedded(data);
|
|
409
|
+
});
|
|
396
410
|
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
|
|
397
411
|
this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
|
|
398
412
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
@@ -469,16 +483,47 @@ export class SessionManager {
|
|
|
469
483
|
if (responseModel && modelsResemble(selectedModel, responseModel)) {
|
|
470
484
|
if (this.#state.responseModel) {
|
|
471
485
|
this.#state.responseModel = "";
|
|
486
|
+
this.stopResponseModelSlide();
|
|
472
487
|
this.tick();
|
|
473
488
|
}
|
|
474
489
|
return;
|
|
475
490
|
}
|
|
476
491
|
if (responseModel && responseModel !== this.#state.responseModel) {
|
|
492
|
+
// A model replacing one already shown swaps in place; only an appearing one slides out.
|
|
493
|
+
if (!this.#state.responseModel) this.startResponseModelSlide();
|
|
477
494
|
this.#state.responseModel = responseModel;
|
|
478
495
|
this.tick();
|
|
479
496
|
}
|
|
480
497
|
}
|
|
481
498
|
|
|
499
|
+
/**
|
|
500
|
+
* Slide a newly shown response model out of the loader while pi-topping-statusline
|
|
501
|
+
* embeds it, matching that status bar's own slides; anywhere else it shows at once.
|
|
502
|
+
* The loader's own timer runs at 50ms or slower, so the slide repaints on its own
|
|
503
|
+
* faster timer until it is done.
|
|
504
|
+
*/
|
|
505
|
+
private startResponseModelSlide(): void {
|
|
506
|
+
this.stopResponseModelSlide();
|
|
507
|
+
const state = this.#state;
|
|
508
|
+
if (!this.#settings.features.responseModel || !this.#loaderInStatusline) {
|
|
509
|
+
state.responseModelShownAt = 0;
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
state.responseModelShownAt = Date.now();
|
|
513
|
+
state.responseModelSlideTimer = setInterval(() => {
|
|
514
|
+
this.tick();
|
|
515
|
+
if (Date.now() - state.responseModelShownAt >= RESPONSE_MODEL_SLIDE_MS) this.stopResponseModelSlide();
|
|
516
|
+
}, RESPONSE_MODEL_SLIDE_FRAME_MS);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
private stopResponseModelSlide(): void {
|
|
520
|
+
const state = this.#state;
|
|
521
|
+
if (state.responseModelSlideTimer) {
|
|
522
|
+
clearInterval(state.responseModelSlideTimer);
|
|
523
|
+
state.responseModelSlideTimer = null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
482
527
|
private cancelResponseModelFade(ctx?: ExtensionContext | null): void {
|
|
483
528
|
const state = this.#state;
|
|
484
529
|
state.responseModelFadeGeneration++;
|
|
@@ -543,11 +588,12 @@ export class SessionManager {
|
|
|
543
588
|
}
|
|
544
589
|
|
|
545
590
|
private pickWorkingWord(): WorkingTextSelection {
|
|
546
|
-
return pickWorkingTextSelection(this.#settings.wordPacks, this.#allPacks);
|
|
591
|
+
return pickWorkingTextSelection(this.#settings.wordPacks, this.#allPacks, this.#settings.features.includeDefaultWorkingText);
|
|
547
592
|
}
|
|
548
593
|
|
|
549
594
|
private resetTurn(now: number): void {
|
|
550
595
|
this.cancelResponseModelFade();
|
|
596
|
+
this.stopResponseModelSlide();
|
|
551
597
|
const state = this.#state;
|
|
552
598
|
state.startTime = now;
|
|
553
599
|
state.shimmerOrigin = now;
|
|
@@ -589,36 +635,9 @@ export class SessionManager {
|
|
|
589
635
|
if (decorations.tokenActivityMonitor) state.activityMeter.push(rateToLevel(tokenRate));
|
|
590
636
|
state.lastTokenRateSampledAt = now;
|
|
591
637
|
}
|
|
592
|
-
const
|
|
593
|
-
?
|
|
594
|
-
: "";
|
|
595
|
-
const responseModelColored = features.responseModel && state.responseModel
|
|
596
|
-
? getResponseModelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel, ctx.model?.provider)(state.responseModel)
|
|
638
|
+
const responseModelShown = features.responseModel && state.responseModel
|
|
639
|
+
? slideOutTail(state.responseModel, now - state.responseModelShownAt)
|
|
597
640
|
: "";
|
|
598
|
-
const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
|
|
599
|
-
if (isFullyDefaultAppearance(features, decorations)) {
|
|
600
|
-
const msg = spinner || responseModel
|
|
601
|
-
? buildWorkingMessage(ctx.ui.theme, { spinner, text: ctx.ui.theme.fg("dim", DEFAULT_WORKING_WORD), responseModel }, this.#settings.loaderOrder)
|
|
602
|
-
: undefined;
|
|
603
|
-
if (msg !== state.lastMessage) {
|
|
604
|
-
state.lastMessage = msg;
|
|
605
|
-
ctx.ui.setWorkingMessage(msg);
|
|
606
|
-
}
|
|
607
|
-
return;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
const word = features.substituteDefaultMessage ? state.workingText.text : DEFAULT_WORKING_WORD;
|
|
611
|
-
const styled = decorations.shimmer
|
|
612
|
-
? shimmerString(word, now - state.shimmerOrigin, ctx.ui.theme, decorations.shimmerDirection, decorations.shimmerSpeed, decorations.shimmerInverted)
|
|
613
|
-
: ctx.ui.theme.fg("text", word);
|
|
614
|
-
let meter = "";
|
|
615
|
-
if (decorations.tokenActivityMonitor) {
|
|
616
|
-
const meterColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.meterColor, ctx.thinkingLevel);
|
|
617
|
-
meter = state.activityMeter.render((level, char) =>
|
|
618
|
-
ActivityMeter.colorizeCell(level, char, ctx.ui.theme, meterColorizer, decorations.meterDimmed),
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
let tokenRateText = "";
|
|
622
641
|
if (features.tokenRate) {
|
|
623
642
|
const latestTokenRate = formatTokenRate(state.rateTracker.tokenRate);
|
|
624
643
|
if (latestTokenRate && hasNewTokenCount) {
|
|
@@ -627,38 +646,33 @@ export class SessionManager {
|
|
|
627
646
|
} else if (now >= state.tokenRateFadeStartsAt + TOKEN_RATE_FADE_MS) {
|
|
628
647
|
state.tokenRateText = "";
|
|
629
648
|
}
|
|
630
|
-
tokenRateText = state.tokenRateText;
|
|
631
|
-
}
|
|
632
|
-
let tokenRateSegment = "";
|
|
633
|
-
if (features.tokenRate) {
|
|
634
|
-
if (!tokenRateText) {
|
|
635
|
-
tokenRateSegment = ctx.ui.theme.fg("dim", TOKEN_RATE_PLACEHOLDER);
|
|
636
|
-
} else {
|
|
637
|
-
const tokenRateColorizer = getThinkingLevelColorizer(ctx.ui.theme, decorations.tokenRateColor, ctx.thinkingLevel);
|
|
638
|
-
tokenRateSegment = now < state.tokenRateFadeStartsAt
|
|
639
|
-
? tokenRateColorizer(tokenRateText)
|
|
640
|
-
: fadeThemeColorString(
|
|
641
|
-
tokenRateText,
|
|
642
|
-
Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
|
|
643
|
-
ctx.ui.theme,
|
|
644
|
-
tokenRateColorizer,
|
|
645
|
-
);
|
|
646
|
-
}
|
|
647
649
|
}
|
|
648
|
-
const
|
|
649
|
-
const msg =
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
650
|
+
const showSpinner = this.spinnerInMessage();
|
|
651
|
+
const msg = isFullyDefaultAppearance(features, decorations) && !showSpinner && !responseModelShown
|
|
652
|
+
? undefined
|
|
653
|
+
: buildLoaderMessage(
|
|
654
|
+
ctx.ui.theme,
|
|
655
|
+
ctx.thinkingLevel,
|
|
656
|
+
ctx.model?.provider,
|
|
657
|
+
features,
|
|
658
|
+
decorations,
|
|
659
|
+
this.#settings.loaderOrder,
|
|
660
|
+
{
|
|
661
|
+
spinnerAtMs: showSpinner ? now : undefined,
|
|
662
|
+
word: state.workingText.text,
|
|
663
|
+
shimmerAtMs: now - state.shimmerOrigin,
|
|
664
|
+
renderMeter: (colorizer, dimmed) => state.activityMeter.render((level, char) =>
|
|
665
|
+
ActivityMeter.colorizeCell(level, char, ctx.ui.theme, colorizer, dimmed),
|
|
666
|
+
),
|
|
667
|
+
elapsedMs: now - state.startTime,
|
|
668
|
+
outputTokens: total,
|
|
669
|
+
tokenRateText: state.tokenRateText,
|
|
670
|
+
tokenRateShade: now < state.tokenRateFadeStartsAt
|
|
671
|
+
? undefined
|
|
672
|
+
: Math.floor((now - state.tokenRateFadeStartsAt) / (TOKEN_RATE_FADE_MS / TOKEN_RATE_FADE_SHADE_COUNT)),
|
|
673
|
+
responseModel: responseModelShown,
|
|
674
|
+
},
|
|
675
|
+
);
|
|
662
676
|
if (msg !== state.lastMessage) {
|
|
663
677
|
state.lastMessage = msg;
|
|
664
678
|
ctx.ui.setWorkingMessage(msg);
|
package/src/settings.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
|
|
5
5
|
import type { MenuSection } from "./menu.ts";
|
|
6
|
-
import { isWordPackEnabled, isWordPackId, type WordPack } from "./word-packs.ts";
|
|
6
|
+
import { isWordPackEnabled, isWordPackId, WORD_PACK_MENU_PREFIX, type WordPack } from "./word-packs.ts";
|
|
7
7
|
import { isPlainObject } from "./util.ts";
|
|
8
8
|
|
|
9
9
|
export const SETTING_COLOR_VALUES = ["accent", "border", "borderAccent", "success", "error", "warning"] as const;
|
|
@@ -20,7 +20,8 @@ export const DONE_MARKER_BORDER_COLOR_VALUES = THINKING_LEVEL_SETTING_COLOR_VALU
|
|
|
20
20
|
export type DoneMarkerBorderColor = ThinkingLevelSettingColor;
|
|
21
21
|
const THINKING_LEVEL_COLOR_MIGRATION_VERSION = 2;
|
|
22
22
|
const DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION = 3;
|
|
23
|
-
|
|
23
|
+
const RESPONSE_MODEL_FOOTER_MIGRATION_VERSION = 4;
|
|
24
|
+
export const SETTINGS_SCHEMA_VERSION = 4;
|
|
24
25
|
|
|
25
26
|
export const BORDER_STYLE_VALUES = ["double", "single", "rounded", "heavy"] as const;
|
|
26
27
|
export type BorderStyle = (typeof BORDER_STYLE_VALUES)[number];
|
|
@@ -96,12 +97,14 @@ export interface DecoratorSettings {
|
|
|
96
97
|
outputTokens: boolean;
|
|
97
98
|
tokenRate: boolean;
|
|
98
99
|
responseModel: boolean;
|
|
100
|
+
responseModelFooter: boolean;
|
|
99
101
|
doneMarker: boolean;
|
|
100
102
|
doneMarkerIcon: boolean;
|
|
101
103
|
randomizeDoneMarker: boolean;
|
|
102
104
|
doneMarkerTokens: boolean;
|
|
103
105
|
doneMarkerInputs: boolean;
|
|
104
106
|
doneMarkerModel: boolean;
|
|
107
|
+
includeDefaultWorkingText: boolean;
|
|
105
108
|
};
|
|
106
109
|
loaderOrder: LoaderElement[];
|
|
107
110
|
wordPacks: Record<string, boolean>;
|
|
@@ -109,7 +112,7 @@ export interface DecoratorSettings {
|
|
|
109
112
|
|
|
110
113
|
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
111
114
|
decorations: { animatedSpinner: true, shimmer: true, shimmerInverted: false, shimmerDirection: "ltr", shimmerDirectionEnabled: true, shimmerSpeed: "normal", shimmerSpeedEnabled: true, tokenActivityMonitor: true, meterDirection: "rtl", meterDirectionEnabled: true, decorateUserPrompt: true, borderColor: "thinking-level", borderColorEnabled: true, borderStyle: "double", borderStyleEnabled: true, doneMarkerBorderStyle: "none", doneMarkerBorderColor: "thinking-level", doneMarkerStyle: "elite", doneMarkerModelColor: "muted", doneMarkerModelDimmed: false, spinnerColor: "thinking-level", 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 },
|
|
112
|
-
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true },
|
|
115
|
+
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true, includeDefaultWorkingText: true },
|
|
113
116
|
loaderOrder: [...DEFAULT_LOADER_ORDER],
|
|
114
117
|
wordPacks: {},
|
|
115
118
|
};
|
|
@@ -148,20 +151,7 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
|
|
|
148
151
|
if (!isPlainObject(parsed)) return merged;
|
|
149
152
|
for (const [key, value] of Object.entries(parsed)) {
|
|
150
153
|
if (!Object.hasOwn(merged, key)) continue;
|
|
151
|
-
|
|
152
|
-
if (typeof merged[key] === "boolean" && typeof value === "boolean") valid = value;
|
|
153
|
-
else if ((key === "meterDirection" || key === "shimmerDirection") && (value === "ltr" || value === "rtl")) valid = value;
|
|
154
|
-
else if (key === "shimmerSpeed" && (value === "slow" || value === "normal" || value === "fast")) valid = value;
|
|
155
|
-
else if (key === "spinnerColor" && value === "default") valid = "thinking-level";
|
|
156
|
-
else if (key === "spinnerColor" && isSpinnerColor(value)) valid = value;
|
|
157
|
-
else if (key === "borderColor" && isPromptBorderColor(value)) valid = value;
|
|
158
|
-
else if (key === "doneMarkerBorderColor" && value === "default") valid = "thinking-level";
|
|
159
|
-
else if (key === "doneMarkerBorderColor" && isDoneMarkerBorderColor(value)) valid = value;
|
|
160
|
-
else if ((key === "meterColor" || key === "tokenRateColor" || key === "responseModelColor" || key === "doneMarkerModelColor") && isThinkingLevelColor(value)) valid = value;
|
|
161
|
-
else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
|
|
162
|
-
else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
|
|
163
|
-
else if (key === "doneMarkerStyle" && isDoneMarkerStyle(value)) valid = value;
|
|
164
|
-
if (valid !== undefined) (merged as Record<string, boolean | string>)[key] = valid;
|
|
154
|
+
if (typeof merged[key] === "boolean" && typeof value === "boolean") (merged as Record<string, boolean | string>)[key] = value;
|
|
165
155
|
}
|
|
166
156
|
return merged;
|
|
167
157
|
}
|
|
@@ -176,7 +166,7 @@ export function fromCycleSpeed(value: unknown): "slow" | "normal" | "fast" {
|
|
|
176
166
|
return value === "slow" || value === "fast" ? value : "normal";
|
|
177
167
|
}
|
|
178
168
|
|
|
179
|
-
type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Options";
|
|
169
|
+
type MenuSectionName = "User Prompt" | "“Working” Loader" | "Completion Marker" | "Word Packs" | "Options";
|
|
180
170
|
type DecorationSettings = DecoratorSettings["decorations"];
|
|
181
171
|
type FeatureSettings = DecoratorSettings["features"];
|
|
182
172
|
type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
|
|
@@ -228,6 +218,8 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
|
228
218
|
{ id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
|
|
229
219
|
{ id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" },
|
|
230
220
|
{ id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
|
|
221
|
+
{ id: "showResponseModelFooter", label: "Response model footer", section: "Options", group: "features", key: "responseModelFooter" },
|
|
222
|
+
{ id: "includeDefaultWorkingText", label: "Include default/random “Working” text", section: "Word Packs", group: "features", key: "includeDefaultWorkingText" },
|
|
231
223
|
];
|
|
232
224
|
|
|
233
225
|
function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["items"][number] {
|
|
@@ -245,10 +237,9 @@ function isDecorationBooleanKey(key: keyof DecorationSettings): key is Decoratio
|
|
|
245
237
|
function setDecorationCycleValue(decorations: DecorationSettings, key: keyof DecorationSettings, value: string): void {
|
|
246
238
|
switch (key) {
|
|
247
239
|
case "borderColor":
|
|
248
|
-
if (isPromptBorderColor(value)) decorations[key] = value;
|
|
249
|
-
return;
|
|
250
240
|
case "spinnerColor":
|
|
251
|
-
|
|
241
|
+
case "doneMarkerBorderColor":
|
|
242
|
+
if (isThinkingLevelSettingColor(value)) decorations[key] = value;
|
|
252
243
|
return;
|
|
253
244
|
case "meterColor":
|
|
254
245
|
case "tokenRateColor":
|
|
@@ -256,9 +247,6 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
|
|
|
256
247
|
case "doneMarkerModelColor":
|
|
257
248
|
if (isThinkingLevelColor(value)) decorations[key] = value;
|
|
258
249
|
return;
|
|
259
|
-
case "doneMarkerBorderColor":
|
|
260
|
-
if (isDoneMarkerBorderColor(value)) decorations[key] = value;
|
|
261
|
-
return;
|
|
262
250
|
case "borderStyle":
|
|
263
251
|
if (isBorderStyle(value)) decorations[key] = value;
|
|
264
252
|
return;
|
|
@@ -287,12 +275,20 @@ function buildSection(title: MenuSectionName, settings: DecoratorSettings): Menu
|
|
|
287
275
|
|
|
288
276
|
export function buildMenuSections(settings: DecoratorSettings, bundledPacks: readonly WordPack[], userPacks: readonly WordPack[] = []): MenuSection[] {
|
|
289
277
|
const packs = [...bundledPacks, ...userPacks];
|
|
278
|
+
const packItems = packs.map((pack) => ({ id: `${WORD_PACK_MENU_PREFIX}${pack.id}`, label: pack.name, value: isWordPackEnabled(pack.id, settings.wordPacks) }));
|
|
279
|
+
const packIds = packItems.map(item => item.id);
|
|
290
280
|
return [
|
|
291
281
|
buildSection("User Prompt", settings),
|
|
292
282
|
buildSection("“Working” Loader", settings),
|
|
293
283
|
{ title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
|
|
294
284
|
buildSection("Completion Marker", settings),
|
|
295
|
-
{
|
|
285
|
+
{
|
|
286
|
+
title: "Word Packs",
|
|
287
|
+
items: [
|
|
288
|
+
...packItems,
|
|
289
|
+
...buildSection("Word Packs", settings).items.map((item, index) => ({ ...item, spacerBefore: index === 0, disabledUnlessAnyOf: packIds })),
|
|
290
|
+
],
|
|
291
|
+
},
|
|
296
292
|
buildSection("Options", settings),
|
|
297
293
|
];
|
|
298
294
|
}
|
|
@@ -312,8 +308,8 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
|
|
|
312
308
|
}
|
|
313
309
|
}
|
|
314
310
|
for (const [id, value] of Object.entries(values)) {
|
|
315
|
-
if (!id.startsWith(
|
|
316
|
-
const packId = id.slice(
|
|
311
|
+
if (!id.startsWith(WORD_PACK_MENU_PREFIX) || typeof value !== "boolean") continue;
|
|
312
|
+
const packId = id.slice(WORD_PACK_MENU_PREFIX.length);
|
|
317
313
|
if (isWordPackId(packId)) next.wordPacks[packId] = value;
|
|
318
314
|
}
|
|
319
315
|
if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]);
|
|
@@ -327,7 +323,8 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
|
|
|
327
323
|
* don't resurface on re-enable.
|
|
328
324
|
* Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and
|
|
329
325
|
* `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker
|
|
330
|
-
* border color is reset.
|
|
326
|
+
* border color is reset. Below 4, the response-model footer inherits the response-model
|
|
327
|
+
* setting. A legacy `features.simCityWorkingText: true` enables the
|
|
331
328
|
* `simcity` pack when no explicit preference is stored.
|
|
332
329
|
*/
|
|
333
330
|
export function loadSettings(): DecoratorSettings {
|
|
@@ -344,6 +341,15 @@ export function loadSettings(): DecoratorSettings {
|
|
|
344
341
|
wordPacks.simcity = true;
|
|
345
342
|
}
|
|
346
343
|
const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks };
|
|
344
|
+
if (isPlainObject(parsed.decorations)) {
|
|
345
|
+
for (const [key, value] of Object.entries(parsed.decorations)) {
|
|
346
|
+
if (!Object.hasOwn(DEFAULT_SETTINGS.decorations, key) || typeof value !== "string") continue;
|
|
347
|
+
const k = key as keyof DecorationSettings;
|
|
348
|
+
if (isDecorationBooleanKey(k)) continue;
|
|
349
|
+
setDecorationCycleValue(settings.decorations, k,
|
|
350
|
+
value === "default" && (k === "spinnerColor" || k === "doneMarkerBorderColor") ? "thinking-level" : value);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
347
353
|
const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
|
|
348
354
|
if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) {
|
|
349
355
|
settings.decorations.spinnerColor = "thinking-level";
|
|
@@ -352,6 +358,9 @@ export function loadSettings(): DecoratorSettings {
|
|
|
352
358
|
if (schemaVersion < DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION) {
|
|
353
359
|
settings.decorations.doneMarkerBorderColor = "thinking-level";
|
|
354
360
|
}
|
|
361
|
+
if (schemaVersion < RESPONSE_MODEL_FOOTER_MIGRATION_VERSION) {
|
|
362
|
+
settings.features.responseModelFooter = settings.features.responseModel;
|
|
363
|
+
}
|
|
355
364
|
for (const entry of MENU_ENTRIES) {
|
|
356
365
|
if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
|
|
357
366
|
setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { isPlainObject } from "./util.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event-bus channel on which pi-topping-statusline announces, as `{ embedded: boolean }`,
|
|
5
|
+
* whether its status bar hosts Pi's working loader. Shared with pi-topping-statusline.
|
|
6
|
+
*/
|
|
7
|
+
export const STATUSLINE_EMBED_CHANNEL = "pi-topping-statusline:working-status-embedded";
|
|
8
|
+
|
|
9
|
+
/** Whether an announcement on STATUSLINE_EMBED_CHANNEL says the status bar hosts the loader. */
|
|
10
|
+
export function announcesEmbedded(data: unknown): boolean {
|
|
11
|
+
return isPlainObject(data) && data.embedded === true;
|
|
12
|
+
}
|
package/src/word-packs.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { isPlainObject, stripControlChars } from "./util.ts";
|
|
6
6
|
import { WORDS, type WordEntry } from "./words.ts";
|
|
7
7
|
|
|
8
|
+
export const WORD_PACK_MENU_PREFIX = "pack:";
|
|
8
9
|
const PACK_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
9
10
|
const RESERVED_BUNDLED_PACK_IDS = new Set(["doctor-who", "firefly", "hitchhikers-guide", "lord-of-the-rings", "matrix", "portal", "simcity", "star-trek", "star-wars"]);
|
|
10
11
|
|
|
@@ -107,14 +108,22 @@ export function isWordPackEnabled(id: string, enabled: Record<string, boolean>):
|
|
|
107
108
|
return enabled[id] === true;
|
|
108
109
|
}
|
|
109
110
|
|
|
110
|
-
export function selectWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[], fraction: number): WorkingTextSelection {
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
const
|
|
115
|
-
|
|
111
|
+
export function selectWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[], fraction: number, includeDefaultWorkingText: boolean): WorkingTextSelection {
|
|
112
|
+
const enabledPacks = packs.filter((pack) => isWordPackEnabled(pack.id, enabled));
|
|
113
|
+
const packWordCount = enabledPacks.reduce((sum, pack) => sum + pack.words.length, 0);
|
|
114
|
+
const baseWords: readonly WordEntry[] = includeDefaultWorkingText || packWordCount === 0 ? WORDS : [];
|
|
115
|
+
const total = baseWords.length + packWordCount;
|
|
116
|
+
let idx = Math.min(total - 1, Math.floor(fraction * total));
|
|
117
|
+
let entry: WordEntry | undefined = baseWords[idx];
|
|
118
|
+
idx -= baseWords.length;
|
|
119
|
+
for (const pack of enabledPacks) {
|
|
120
|
+
if (entry) break;
|
|
121
|
+
entry = pack.words[idx];
|
|
122
|
+
idx -= pack.words.length;
|
|
123
|
+
}
|
|
124
|
+
return { text: entry!.present_tense, pastTense: entry!.past_tense };
|
|
116
125
|
}
|
|
117
126
|
|
|
118
|
-
export function pickWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[]): WorkingTextSelection {
|
|
119
|
-
return selectWorkingTextSelection(enabled, packs, Math.random());
|
|
127
|
+
export function pickWorkingTextSelection(enabled: Record<string, boolean>, packs: readonly WordPack[], includeDefaultWorkingText: boolean): WorkingTextSelection {
|
|
128
|
+
return selectWorkingTextSelection(enabled, packs, Math.random(), includeDefaultWorkingText);
|
|
120
129
|
}
|