@underactive/pi-topping 0.7.0 → 0.7.2
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 +16 -8
- package/package.json +3 -3
- package/src/format.ts +12 -7
- package/src/menu.ts +6 -5
- package/src/nvidia-green.ts +37 -0
- package/src/pi-installer.ts +15 -6
- package/src/preview.ts +9 -2
- package/src/prompt-decorator.ts +8 -3
- package/src/session.ts +23 -10
- package/src/settings.ts +14 -3
- package/src/setup-command.ts +94 -48
- package/src/toppings.ts +84 -21
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is a [Pi](https://github.com/earendil-works/pi-coding-agent) extension that adds some quality of life improvements to Pi's UX, while looking good in the process:
|
|
4
4
|
|
|
5
|
-
**User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, timestamp, and provider/model label. Prompts submitted while Pi is working (steer / `Alt+Enter` follow-up) are left undecorated so Pi's native `Steering:` / `Follow-up:` queue rows still render.
|
|
5
|
+
**User Prompt** — high-vis bordered prompt box with configurable border color and style, pi icon toggle, timestamp, and provider/model label. The box, timestamp, and provider/model label are display-only; Pi sends your prompt text unchanged to the model, so they do not affect LLM prompt caching. Prompts submitted while Pi is working (steer / `Alt+Enter` follow-up) are left undecorated so Pi's native `Steering:` / `Follow-up:` queue rows still render.
|
|
6
6
|
|
|
7
7
|

|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
|
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
-
**Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event.
|
|
13
|
+
**Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. It can append the response model when it differs from the selected model, with its own color option. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -22,6 +22,10 @@ pi install npm:@underactive/pi-topping
|
|
|
22
22
|
|
|
23
23
|
Restart Pi (or run `/reload`) to pick it up.
|
|
24
24
|
|
|
25
|
+
### Sibling toppings
|
|
26
|
+
|
|
27
|
+
The sibling-topping setup flow currently ships behind the `PI_TOPPING_SIBLING_SETUP=1` environment flag. When enabled, run `/topping-setup` in TUI mode to selectively install missing Pi Topping sibling extensions or uninstall sibling packages recorded in Pi's user settings. Installation rows are selected by default; removal rows are unchecked by default. Restart Pi after applying package changes. Use `/topping-setup disable-side-toppings-check` to suppress missing-topping notices, or `enable-side-toppings-check` to restore them.
|
|
28
|
+
|
|
25
29
|
## Settings
|
|
26
30
|
|
|
27
31
|
Run `/topping-settings` (TUI only) to customize your toppings. Settings persist to
|
|
@@ -61,7 +65,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
61
65
|
║ [■] Token rate dimmed OFF ║
|
|
62
66
|
║ [■] Response model ON ║
|
|
63
67
|
║ [■] Response model color ‹ accent › ║
|
|
64
|
-
║ [■] Response model dimmed
|
|
68
|
+
║ [■] Response model dimmed OFF ║
|
|
65
69
|
║ ║
|
|
66
70
|
╟─ Elements Order ──────────────────────────────────────────────╢
|
|
67
71
|
║ [ ] Animated spinner ║
|
|
@@ -81,6 +85,9 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
81
85
|
║ [■] Randomize “Worked” text ON ║
|
|
82
86
|
║ [■] Tokens spent ON ║
|
|
83
87
|
║ [■] Mid-turn inputs ON ║
|
|
88
|
+
║ [■] Response model ON ║
|
|
89
|
+
║ [■] Response model color ‹ muted › ║
|
|
90
|
+
║ [■] Response model dimmed OFF ║
|
|
84
91
|
║ ║
|
|
85
92
|
╟─ Word Packs ──────────────────────────────────────────────────╢
|
|
86
93
|
║ [ ] Doctor Who OFF ║
|
|
@@ -98,7 +105,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
98
105
|
╟───────────────────────────────────────────────────────────────╢
|
|
99
106
|
║ ↑↓ move PgUp/PgDn page ←→ select ║
|
|
100
107
|
║ ␣ toggle ⏎ apply esc cancel ║
|
|
101
|
-
╚═══════════════════════════════════════════════════════[ 9/
|
|
108
|
+
╚═══════════════════════════════════════════════════════[ 9/54 ]╝
|
|
102
109
|
```
|
|
103
110
|
|
|
104
111
|
### Word packs
|
|
@@ -112,14 +119,15 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
|
|
|
112
119
|
"packs": [{
|
|
113
120
|
"id": "cooking",
|
|
114
121
|
"name": "Cooking",
|
|
122
|
+
"description": "Kitchen operations.",
|
|
115
123
|
"words": [{ "present_tense": "Sautéing onions", "past_tense": "Sautéed" }]
|
|
116
124
|
}]
|
|
117
125
|
}
|
|
118
126
|
```
|
|
119
127
|
|
|
120
|
-
Pack IDs must start with a lowercase letter
|
|
128
|
+
Pack IDs must start with a lowercase letter, contain only lowercase letters, digits, and `-`, and be at most 64 characters. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. An optional `description` appears in the settings preview when the pack row is selected, and an optional `attribution` records the source. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
|
|
121
129
|
|
|
122
|
-
|
|
130
|
+
Every color setting offers `thinking-level` (shown as `thinkingLevel` in `/topping-settings`); it is the default for the animated spinner, the prompt border, and the completion marker border. The spinner, token activity monitor, token rate, and response model follow Pi's current thinking level. The prompt border uses the level captured when the prompt was submitted, and the completion marker uses the level captured when the turn settled, so earlier entries keep their color after you change level. When hand-editing `settings.json`, write `thinking-level`; the legacy `default` spelling is only recognised for `spinnerColor` and `doneMarkerBorderColor`.
|
|
123
131
|
|
|
124
132
|
Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
|
|
125
133
|
left or right within the loader. Elapsed time, output tokens, token rate, and response model are joined with
|
|
@@ -133,7 +141,7 @@ full brightness for 1.5 seconds, then fades through five theme-aware shades to t
|
|
|
133
141
|
the next 0.25 seconds before returning to the placeholder; a new count restores full brightness and
|
|
134
142
|
restarts the cycle.
|
|
135
143
|
|
|
136
|
-
The response model is captured from assistant responses, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. Equivalent local paths and decorated filenames are suppressed. It can be hidden, recolored, permanently dimmed, or reordered. After the agent settles, its final value remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears; new work cancels the pending fade.
|
|
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. It can be hidden, recolored, permanently dimmed, or reordered. When the active provider is `switchyard`, the loader and its post-settlement hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies. 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.
|
|
137
145
|
|
|
138
146
|
## Requirements
|
|
139
147
|
|
|
@@ -149,7 +157,7 @@ npm test
|
|
|
149
157
|
npm run typecheck
|
|
150
158
|
```
|
|
151
159
|
|
|
152
|
-
Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior, timer resets, settings persistence, the `menu.ts` component,
|
|
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 loading, the `menu.ts` component, the settings menu wiring end to end, and the flag-gated `/topping-setup` command's install and uninstall flows.
|
|
153
161
|
|
|
154
162
|
## Limitations
|
|
155
163
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
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.86.0",
|
|
34
|
+
"@earendil-works/pi-tui": "0.86.0",
|
|
35
35
|
"@types/node": "^24.0.0",
|
|
36
36
|
"typescript": "^5.9.3"
|
|
37
37
|
},
|
package/src/format.ts
CHANGED
|
@@ -44,8 +44,13 @@ export type LoaderElement = "spinner" | "text" | "meter" | "elapsed" | "tokens"
|
|
|
44
44
|
/** Default first-use working-indicator element order. */
|
|
45
45
|
export const DEFAULT_LOADER_ORDER: readonly LoaderElement[] = ["spinner", "text", "meter", "tokenRate", "elapsed", "tokens", "responseModel"];
|
|
46
46
|
|
|
47
|
-
/** Elements share a detail group;
|
|
48
|
-
const
|
|
47
|
+
/** Elements share a detail group; "raw" details arrive pre-colored and must not be dimmed. */
|
|
48
|
+
const DETAIL_STYLES = new Map<LoaderElement, "muted" | "raw">([
|
|
49
|
+
["elapsed", "muted"],
|
|
50
|
+
["tokens", "muted"],
|
|
51
|
+
["tokenRate", "raw"],
|
|
52
|
+
["responseModel", "raw"],
|
|
53
|
+
]);
|
|
49
54
|
|
|
50
55
|
const TOKEN_UNITS = [
|
|
51
56
|
{ threshold: 10_000, divisor: 1_000, decimals: 1, suffix: "k" },
|
|
@@ -91,7 +96,7 @@ export function dimAttribute(text: string): string {
|
|
|
91
96
|
return `\x1b[2m${text}\x1b[22m`;
|
|
92
97
|
}
|
|
93
98
|
|
|
94
|
-
/** Number of discrete, eased
|
|
99
|
+
/** Number of discrete, eased shades used by the token-rate and response-model fades. */
|
|
95
100
|
export const TOKEN_RATE_FADE_SHADE_COUNT = 5;
|
|
96
101
|
|
|
97
102
|
/** Milliseconds a settled response model remains fully visible before fading. */
|
|
@@ -100,7 +105,7 @@ export const RESPONSE_MODEL_HOLD_MS = 3_000;
|
|
|
100
105
|
/** Milliseconds over which a settled response model fades through the theme shades. */
|
|
101
106
|
export const RESPONSE_MODEL_FADE_MS = 500;
|
|
102
107
|
|
|
103
|
-
/** Render one
|
|
108
|
+
/** Render one fade shade by blending the colorizer's color toward the active theme's dim color. */
|
|
104
109
|
export function fadeThemeColorString(
|
|
105
110
|
text: string,
|
|
106
111
|
shade: number,
|
|
@@ -169,9 +174,9 @@ export function buildWorkingMessage(
|
|
|
169
174
|
for (const element of order) {
|
|
170
175
|
const value = parts[element];
|
|
171
176
|
if (!value) continue;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
details.push(
|
|
177
|
+
const style = DETAIL_STYLES.get(element);
|
|
178
|
+
if (style) {
|
|
179
|
+
details.push(style === "raw" ? value : theme.fg("muted", value));
|
|
175
180
|
continue;
|
|
176
181
|
}
|
|
177
182
|
flushDetails();
|
package/src/menu.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* contract and `ctx.ui.custom()` overlay API.
|
|
5
5
|
*
|
|
6
6
|
* Renders a titled box containing one or more sections of boolean toggle,
|
|
7
|
-
* multi-value cycle, or
|
|
7
|
+
* multi-value cycle, or keyboard-reorderable items, plus an optional live-updating
|
|
8
8
|
* preview section driven by a caller-supplied render callback.
|
|
9
9
|
*
|
|
10
10
|
* Intended to be reused by any extension that needs a simple modal toggle
|
|
@@ -57,11 +57,12 @@ export interface MenuConfig {
|
|
|
57
57
|
/**
|
|
58
58
|
* Optional preview renderer, shown in its own "Preview" section above the
|
|
59
59
|
* toggle sections. Called on every render with the menu's current (possibly
|
|
60
|
-
* toggled but not-yet-applied) values
|
|
61
|
-
*
|
|
62
|
-
*
|
|
60
|
+
* toggled but not-yet-applied) values, the milliseconds elapsed since the menu
|
|
61
|
+
* opened, the id of the item under the cursor (undefined for an empty menu),
|
|
62
|
+
* and the width available to each line. Lines may contain ANSI styling and
|
|
63
|
+
* are truncated/padded to fit automatically.
|
|
63
64
|
*
|
|
64
|
-
* Omitting `nextRefreshInMs`
|
|
65
|
+
* Omitting `nextRefreshInMs` (or returning a value <= 0) makes the preview static.
|
|
65
66
|
*/
|
|
66
67
|
preview?: (values: Record<string, MenuValue>, elapsedMs: number, activeItemId: string | undefined, width: number) => PreviewResult;
|
|
67
68
|
/** Optional heading for the preview block. Defaults to `Preview`. */
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getThinkingLevelColorizer, type ThinkingLevel } from "./format.ts";
|
|
3
|
+
import type { ThinkingLevelColor, ThinkingLevelSettingColor } from "./settings.ts";
|
|
4
|
+
|
|
5
|
+
/** Provider id whose working-loader response model uses NVIDIA green. */
|
|
6
|
+
export const SWITCHYARD_PROVIDER = "switchyard";
|
|
7
|
+
|
|
8
|
+
/** Bright NVIDIA green shared with pi-topping-statusline's Switchyard border. */
|
|
9
|
+
export const NVIDIA_GREEN_HEX = "#84c51a";
|
|
10
|
+
|
|
11
|
+
const NVIDIA_GREEN_RGB = [1, 3, 5].map((offset) => Number.parseInt(NVIDIA_GREEN_HEX.slice(offset, offset + 2), 16));
|
|
12
|
+
const NVIDIA_GREEN_ANSI = `\x1b[38;2;${NVIDIA_GREEN_RGB.join(";")}m`;
|
|
13
|
+
|
|
14
|
+
export function isSwitchyardProvider(provider: string | undefined): boolean {
|
|
15
|
+
return provider?.trim().toLowerCase() === SWITCHYARD_PROVIDER;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function colorizeNvidiaGreen(text: string): string {
|
|
19
|
+
return `${NVIDIA_GREEN_ANSI}${text}\x1b[39m`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build the working-loader response-model colorizer.
|
|
24
|
+
*
|
|
25
|
+
* Switchyard always uses NVIDIA green; other providers retain the configured
|
|
26
|
+
* theme/thinking-level color.
|
|
27
|
+
*/
|
|
28
|
+
export function getResponseModelColorizer(
|
|
29
|
+
theme: Pick<Theme, "fg" | "getThinkingBorderColor">,
|
|
30
|
+
color: ThinkingLevelColor | ThinkingLevelSettingColor,
|
|
31
|
+
thinkingLevel: ThinkingLevel | undefined,
|
|
32
|
+
provider: string | undefined,
|
|
33
|
+
): (text: string) => string {
|
|
34
|
+
return isSwitchyardProvider(provider)
|
|
35
|
+
? colorizeNvidiaGreen
|
|
36
|
+
: getThinkingLevelColorizer(theme, color, thinkingLevel);
|
|
37
|
+
}
|
package/src/pi-installer.ts
CHANGED
|
@@ -9,17 +9,16 @@ export interface PiInstallResult {
|
|
|
9
9
|
const SIGKILL_GRACE_MS = 5_000;
|
|
10
10
|
const EXIT_TIMEOUT = 124;
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
12
|
+
function spawnPiPackageCommand(command: "install" | "remove", spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
14
13
|
if (!/^npm:@?[\w.-]+(\/[\w.-]+)?$/.test(spec)) {
|
|
15
14
|
return Promise.resolve({ code: 1, stdout: "", stderr: `invalid spec: ${spec}` });
|
|
16
15
|
}
|
|
17
16
|
return new Promise((resolve) => {
|
|
18
17
|
const isWindows = process.platform === "win32";
|
|
19
|
-
const [
|
|
20
|
-
? (["cmd.exe", ["/c", "pi",
|
|
21
|
-
: (["pi", [
|
|
22
|
-
const processHandle = spawn(
|
|
18
|
+
const [executable, args, options] = isWindows
|
|
19
|
+
? (["cmd.exe", ["/c", "pi", command, spec], { windowsHide: true }] as const)
|
|
20
|
+
: (["pi", [command, spec], {}] as const);
|
|
21
|
+
const processHandle = spawn(executable, args, { ...options, stdio: ["ignore", "pipe", "pipe"] });
|
|
23
22
|
let stdout = "";
|
|
24
23
|
let stderr = "";
|
|
25
24
|
let settled = false;
|
|
@@ -61,3 +60,13 @@ export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInsta
|
|
|
61
60
|
});
|
|
62
61
|
});
|
|
63
62
|
}
|
|
63
|
+
|
|
64
|
+
/** Run `pi install <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
|
|
65
|
+
export function spawnPiInstall(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
66
|
+
return spawnPiPackageCommand("install", spec, timeoutMs);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Run `pi remove <spec>` without relying on Pi's shell-less exec wrapper on Windows. */
|
|
70
|
+
export function spawnPiRemove(spec: string, timeoutMs: number): Promise<PiInstallResult> {
|
|
71
|
+
return spawnPiPackageCommand("remove", spec, timeoutMs);
|
|
72
|
+
}
|
package/src/preview.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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
4
|
import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatElapsed, formatTokenRate, formatTokens, getThinkingLevelColorizer, isFullyDefaultAppearance, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, shimmerString, SPINNER_FRAME_MS, SPINNER_FRAMES } from "./format.ts";
|
|
5
|
+
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
5
6
|
import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
6
7
|
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
|
|
7
8
|
import { isWordPackEnabled, selectWorkingTextSelection, wordPacksPath, type WordPack } from "./word-packs.ts";
|
|
@@ -62,7 +63,7 @@ export class PreviewRenderer {
|
|
|
62
63
|
const order = parseLoaderOrder(values[LOADER_ORDER_ID]);
|
|
63
64
|
let responseModelColor = DEFAULT_SETTINGS.decorations.responseModelColor;
|
|
64
65
|
if (isThinkingLevelColor(values.responseModelColor)) responseModelColor = values.responseModelColor;
|
|
65
|
-
const responseModelColorizer =
|
|
66
|
+
const responseModelColorizer = getResponseModelColorizer(this.#ctx.ui.theme, responseModelColor, this.#ctx.thinkingLevel, this.#ctx.model?.provider);
|
|
66
67
|
const responseModelColored = features.responseModel ? responseModelColorizer("test-model") : "";
|
|
67
68
|
const responseModel = responseModelColored && values.responseModelDimmed === true ? dimAttribute(responseModelColored) : responseModelColored;
|
|
68
69
|
if (isFullyDefaultAppearance(features, decorations)) {
|
|
@@ -173,7 +174,13 @@ export class PreviewRenderer {
|
|
|
173
174
|
const details: string[] = [];
|
|
174
175
|
if (values.doneMarkerTokens === true) details.push("↓ 949 tokens");
|
|
175
176
|
if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
|
|
176
|
-
|
|
177
|
+
let model = "";
|
|
178
|
+
if (values.doneMarkerModel === true) {
|
|
179
|
+
const color = isThinkingLevelColor(values.doneMarkerModelColor) ? values.doneMarkerModelColor : DEFAULT_SETTINGS.decorations.doneMarkerModelColor;
|
|
180
|
+
const modelColored = getThinkingLevelColorizer(theme, color, this.#ctx.thinkingLevel)("test-model");
|
|
181
|
+
model = values.doneMarkerModelDimmed === true ? dimAttribute(modelColored) : modelColored;
|
|
182
|
+
}
|
|
183
|
+
const content = buildCompletionMarkerContent(theme, icon, word, "2m 52s", details, model);
|
|
177
184
|
const borderStyle = isDoneMarkerBorderStyle(values.doneMarkerBorderStyle) ? values.doneMarkerBorderStyle : "none";
|
|
178
185
|
const borderColor = isDoneMarkerBorderColor(values.doneMarkerBorderColor) ? values.doneMarkerBorderColor : DEFAULT_SETTINGS.decorations.doneMarkerBorderColor;
|
|
179
186
|
const markerStyle = isDoneMarkerStyle(values.doneMarkerStyle) ? values.doneMarkerStyle : DEFAULT_SETTINGS.decorations.doneMarkerStyle;
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -15,7 +15,10 @@ const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; b
|
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* False visibility options hide each corresponding part; present provider and
|
|
18
|
-
* model values join with `/` in the bottom border.
|
|
18
|
+
* model values join with `/` in the bottom border. `thinkingLevel` is the level
|
|
19
|
+
* captured at submission and is only used when `borderColor` is `thinking-level`.
|
|
20
|
+
* Unrecognised `borderStyle` / `borderColor` values fall back to `double` and the
|
|
21
|
+
* default border color.
|
|
19
22
|
*/
|
|
20
23
|
export interface PromptBoxDetails {
|
|
21
24
|
submittedAt?: number;
|
|
@@ -38,16 +41,18 @@ type PromptTheme = {
|
|
|
38
41
|
|
|
39
42
|
const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
|
|
40
43
|
|
|
41
|
-
/** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary. */
|
|
44
|
+
/** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary, then an optional pre-styled model suffix. */
|
|
42
45
|
export function buildCompletionMarkerContent(
|
|
43
46
|
theme: PromptTheme,
|
|
44
47
|
icon: string,
|
|
45
48
|
word: string,
|
|
46
49
|
elapsed: string,
|
|
47
50
|
details: string[],
|
|
51
|
+
model = "",
|
|
48
52
|
): string {
|
|
49
53
|
const tail = details.length ? ` (${details.join(" · ")})` : "";
|
|
50
|
-
|
|
54
|
+
const summary = theme.fg("dim", `${icon ? " " : ""}${word} for ${elapsed}${tail}`);
|
|
55
|
+
return `${icon}${summary}${model ? `${theme.fg("dim", " · ")}${model}` : ""}`;
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
/** Build one decorated completion-marker line, clipped to the available width. */
|
package/src/session.ts
CHANGED
|
@@ -40,12 +40,13 @@ import {
|
|
|
40
40
|
} from "./format.ts";
|
|
41
41
|
import { isSiblingSetupEnabled } from "./flags.ts";
|
|
42
42
|
import { showMenu } from "./menu.ts";
|
|
43
|
+
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
43
44
|
import { registerSetupCommand } from "./setup-command.ts";
|
|
44
45
|
import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
|
|
45
|
-
import {
|
|
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";
|
|
48
|
-
import { modelsResemble, stripControlChars } from "./util.ts";
|
|
49
|
+
import { isPlainObject, modelsResemble, stripControlChars } from "./util.ts";
|
|
49
50
|
import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
|
|
50
51
|
|
|
51
52
|
type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
|
|
@@ -77,6 +78,7 @@ interface DoneEntryData {
|
|
|
77
78
|
tokens?: number;
|
|
78
79
|
midTurnInputs?: number;
|
|
79
80
|
thinkingLevel?: ThinkingLevel;
|
|
81
|
+
model?: string;
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
interface SessionState {
|
|
@@ -148,6 +150,10 @@ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
|
|
|
148
150
|
return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
function responseModelOf(message: unknown): unknown {
|
|
154
|
+
return isPlainObject(message) ? message.responseModel : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
151
157
|
/** Owns mutable extension state and Pi lifecycle registrations. */
|
|
152
158
|
export class SessionManager {
|
|
153
159
|
#counter = new StreamingWordCounter();
|
|
@@ -175,7 +181,7 @@ export class SessionManager {
|
|
|
175
181
|
this.#state.activityMeter.setDirection(this.#settings.decorations.meterDirection);
|
|
176
182
|
if (this.usable(ctx)) {
|
|
177
183
|
this.applyIndicator(ctx);
|
|
178
|
-
if (isSiblingSetupEnabled())
|
|
184
|
+
if (isSiblingSetupEnabled()) announceMissingToppingsOnce(this.#pi);
|
|
179
185
|
}
|
|
180
186
|
};
|
|
181
187
|
|
|
@@ -232,7 +238,7 @@ export class SessionManager {
|
|
|
232
238
|
if (this.usable(ctx) && event.message.role === "assistant") {
|
|
233
239
|
this.#state.liveTokens = 0;
|
|
234
240
|
this.#counter.reset();
|
|
235
|
-
this.updateResponseModel((event.message
|
|
241
|
+
this.updateResponseModel(responseModelOf(event.message));
|
|
236
242
|
}
|
|
237
243
|
};
|
|
238
244
|
|
|
@@ -247,7 +253,7 @@ export class SessionManager {
|
|
|
247
253
|
this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
|
|
248
254
|
}
|
|
249
255
|
const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
|
|
250
|
-
?? (event.message
|
|
256
|
+
?? responseModelOf(event.message);
|
|
251
257
|
this.updateResponseModel(rawResponseModel);
|
|
252
258
|
};
|
|
253
259
|
|
|
@@ -268,7 +274,7 @@ export class SessionManager {
|
|
|
268
274
|
}
|
|
269
275
|
this.#state.liveTokens = 0;
|
|
270
276
|
this.#counter.reset();
|
|
271
|
-
this.updateResponseModel((event.message
|
|
277
|
+
this.updateResponseModel(responseModelOf(event.message));
|
|
272
278
|
};
|
|
273
279
|
|
|
274
280
|
#onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
@@ -324,6 +330,7 @@ export class SessionManager {
|
|
|
324
330
|
tokens: this.#state.confirmTokens,
|
|
325
331
|
midTurnInputs: this.#state.midTurnInputs,
|
|
326
332
|
thinkingLevel: ctx.thinkingLevel,
|
|
333
|
+
model: this.#state.responseModel || undefined,
|
|
327
334
|
});
|
|
328
335
|
}
|
|
329
336
|
this.#currentCtx = null;
|
|
@@ -346,11 +353,16 @@ export class SessionManager {
|
|
|
346
353
|
details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
|
|
347
354
|
}
|
|
348
355
|
const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
|
|
349
|
-
const
|
|
356
|
+
const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
|
|
357
|
+
const rawModel = typeof entry.data.model === "string" ? stripControlChars(entry.data.model).trim() : "";
|
|
358
|
+
const modelColored = features.doneMarkerModel && rawModel
|
|
359
|
+
? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
|
|
360
|
+
: "";
|
|
361
|
+
const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
|
|
362
|
+
const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model);
|
|
350
363
|
const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
|
|
351
364
|
const borderColor = this.#settings.decorations.doneMarkerBorderColor;
|
|
352
365
|
const markerStyle = this.#settings.decorations.doneMarkerStyle;
|
|
353
|
-
const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
|
|
354
366
|
if (borderStyle === "none") return new Text(markerContent);
|
|
355
367
|
|
|
356
368
|
let cachedWidth: number | undefined;
|
|
@@ -383,6 +395,7 @@ export class SessionManager {
|
|
|
383
395
|
this.#pi.on("ui_prompt_end", this.#onUIPromptEnd);
|
|
384
396
|
this.#pi.on("session_shutdown", this.#onSessionShutdown);
|
|
385
397
|
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
|
|
398
|
+
if (isSiblingSetupEnabled()) this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
|
|
386
399
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
387
400
|
this.#pi.registerCommand("topping-settings", {
|
|
388
401
|
description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
|
|
@@ -487,7 +500,7 @@ export class SessionManager {
|
|
|
487
500
|
private startResponseModelFade(ctx: ExtensionContext, model: string, color: ThinkingLevelColor, dimmed: boolean): void {
|
|
488
501
|
this.cancelResponseModelFade(ctx);
|
|
489
502
|
const generation = this.#state.responseModelFadeGeneration;
|
|
490
|
-
const colorizer =
|
|
503
|
+
const colorizer = getResponseModelColorizer(ctx.ui.theme, color, ctx.thinkingLevel, ctx.model?.provider);
|
|
491
504
|
const render = (shade?: number): void => {
|
|
492
505
|
const colored = shade === undefined
|
|
493
506
|
? colorizer(model)
|
|
@@ -581,7 +594,7 @@ export class SessionManager {
|
|
|
581
594
|
? getThinkingLevelColorizer(ctx.ui.theme, decorations.spinnerColor, ctx.thinkingLevel)(SPINNER_FRAMES[Math.floor(now / SPINNER_FRAME_MS) % SPINNER_FRAMES.length]!)
|
|
582
595
|
: "";
|
|
583
596
|
const responseModelColored = features.responseModel && state.responseModel
|
|
584
|
-
?
|
|
597
|
+
? getResponseModelColorizer(ctx.ui.theme, decorations.responseModelColor, ctx.thinkingLevel, ctx.model?.provider)(state.responseModel)
|
|
585
598
|
: "";
|
|
586
599
|
const responseModel = responseModelColored && decorations.responseModelDimmed ? dimAttribute(responseModelColored) : responseModelColored;
|
|
587
600
|
if (isFullyDefaultAppearance(features, decorations)) {
|
package/src/settings.ts
CHANGED
|
@@ -73,6 +73,8 @@ export interface DecoratorSettings {
|
|
|
73
73
|
doneMarkerBorderStyle: DoneMarkerBorderStyle;
|
|
74
74
|
doneMarkerBorderColor: DoneMarkerBorderColor;
|
|
75
75
|
doneMarkerStyle: DoneMarkerStyle;
|
|
76
|
+
doneMarkerModelColor: ThinkingLevelColor;
|
|
77
|
+
doneMarkerModelDimmed: boolean;
|
|
76
78
|
spinnerColor: SpinnerColor;
|
|
77
79
|
spinnerColorEnabled: boolean;
|
|
78
80
|
meterColor: ThinkingLevelColor;
|
|
@@ -99,14 +101,15 @@ export interface DecoratorSettings {
|
|
|
99
101
|
randomizeDoneMarker: boolean;
|
|
100
102
|
doneMarkerTokens: boolean;
|
|
101
103
|
doneMarkerInputs: boolean;
|
|
104
|
+
doneMarkerModel: boolean;
|
|
102
105
|
};
|
|
103
106
|
loaderOrder: LoaderElement[];
|
|
104
107
|
wordPacks: Record<string, boolean>;
|
|
105
108
|
}
|
|
106
109
|
|
|
107
110
|
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
108
|
-
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", 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 },
|
|
109
|
-
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true },
|
|
111
|
+
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 },
|
|
110
113
|
loaderOrder: [...DEFAULT_LOADER_ORDER],
|
|
111
114
|
wordPacks: {},
|
|
112
115
|
};
|
|
@@ -154,7 +157,7 @@ function mergeGroup<T extends Record<string, boolean | string>>(defaults: T, par
|
|
|
154
157
|
else if (key === "borderColor" && isPromptBorderColor(value)) valid = value;
|
|
155
158
|
else if (key === "doneMarkerBorderColor" && value === "default") valid = "thinking-level";
|
|
156
159
|
else if (key === "doneMarkerBorderColor" && isDoneMarkerBorderColor(value)) valid = value;
|
|
157
|
-
else if ((key === "meterColor" || key === "tokenRateColor" || key === "responseModelColor") && isThinkingLevelColor(value)) valid = value;
|
|
160
|
+
else if ((key === "meterColor" || key === "tokenRateColor" || key === "responseModelColor" || key === "doneMarkerModelColor") && isThinkingLevelColor(value)) valid = value;
|
|
158
161
|
else if (key === "borderStyle" && isBorderStyle(value)) valid = value;
|
|
159
162
|
else if (key === "doneMarkerBorderStyle" && isDoneMarkerBorderStyle(value)) valid = value;
|
|
160
163
|
else if (key === "doneMarkerStyle" && isDoneMarkerStyle(value)) valid = value;
|
|
@@ -221,6 +224,9 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
|
221
224
|
{ id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
|
|
222
225
|
{ id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
|
|
223
226
|
{ id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
|
|
227
|
+
{ id: "doneMarkerModel", label: "Response model", section: "Completion Marker", group: "features", key: "doneMarkerModel" },
|
|
228
|
+
{ id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
|
|
229
|
+
{ id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" },
|
|
224
230
|
{ id: "useNerdFont", label: "Use NerdFont icons", section: "Options", group: "decorations", key: "useNerdFont" },
|
|
225
231
|
];
|
|
226
232
|
|
|
@@ -247,6 +253,7 @@ function setDecorationCycleValue(decorations: DecorationSettings, key: keyof Dec
|
|
|
247
253
|
case "meterColor":
|
|
248
254
|
case "tokenRateColor":
|
|
249
255
|
case "responseModelColor":
|
|
256
|
+
case "doneMarkerModelColor":
|
|
250
257
|
if (isThinkingLevelColor(value)) decorations[key] = value;
|
|
251
258
|
return;
|
|
252
259
|
case "doneMarkerBorderColor":
|
|
@@ -318,6 +325,10 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
|
|
|
318
325
|
* to defaults on any read/parse error. When a cycle setting's enabled flag is
|
|
319
326
|
* off, its stored value resets to the menu's disabled default so stale choices
|
|
320
327
|
* don't resurface on re-enable.
|
|
328
|
+
* Files with `schemaVersion` below 2 get spinner color reset to `thinking-level` and
|
|
329
|
+
* `borderAccent` prompt borders moved to `thinking-level`; below 3, the completion-marker
|
|
330
|
+
* border color is reset. A legacy `features.simCityWorkingText: true` enables the
|
|
331
|
+
* `simcity` pack when no explicit preference is stored.
|
|
321
332
|
*/
|
|
322
333
|
export function loadSettings(): DecoratorSettings {
|
|
323
334
|
try {
|
package/src/setup-command.ts
CHANGED
|
@@ -1,74 +1,99 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { hyperlink } from "@earendil-works/pi-tui";
|
|
3
|
-
import { showMenu } from "./menu.ts";
|
|
4
|
-
import { type PiInstallResult, spawnPiInstall } from "./pi-installer.ts";
|
|
5
|
-
import { stripControlChars } from "./util.ts";
|
|
3
|
+
import { type MenuValue, showMenu } from "./menu.ts";
|
|
4
|
+
import { type PiInstallResult, spawnPiInstall, spawnPiRemove } from "./pi-installer.ts";
|
|
6
5
|
import { setSetupCheckDisabled } from "./setup-check.ts";
|
|
7
|
-
import {
|
|
6
|
+
import { detectToppings, type ToppingStatus } from "./toppings.ts";
|
|
7
|
+
import { stripControlChars } from "./util.ts";
|
|
8
8
|
|
|
9
|
-
const
|
|
9
|
+
const PI_COMMAND_TIMEOUT_MS = 120_000;
|
|
10
10
|
const STDERR_SNIPPET_CHARS = 300;
|
|
11
11
|
|
|
12
|
-
type
|
|
12
|
+
type PackageAction = "install" | "remove";
|
|
13
|
+
type PiPackageRunner = (spec: string, timeoutMs: number) => Promise<PiInstallResult>;
|
|
14
|
+
type FailedAction = { action: PackageAction; spec: string; error: string };
|
|
15
|
+
type SetupRow = { status: ToppingStatus; action: PackageAction };
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
function selectedRows(
|
|
18
|
+
rows: ReadonlyMap<string, SetupRow>,
|
|
19
|
+
values: Record<string, boolean>,
|
|
20
|
+
action: PackageAction,
|
|
21
|
+
): ToppingStatus[] {
|
|
22
|
+
return [...rows.entries()]
|
|
23
|
+
.filter(([id, row]) => row.action === action && values[id] === true)
|
|
24
|
+
.map(([, row]) => row.status);
|
|
18
25
|
}
|
|
19
26
|
|
|
20
|
-
function renderDescription(
|
|
21
|
-
|
|
27
|
+
function renderDescription(
|
|
28
|
+
rows: ReadonlyMap<string, SetupRow>,
|
|
29
|
+
activeItemId: string | undefined,
|
|
30
|
+
values: Record<string, MenuValue>,
|
|
31
|
+
): string[] {
|
|
32
|
+
if (activeItemId === undefined) return ["Select a topping to see what it does."];
|
|
33
|
+
const active = rows.get(activeItemId);
|
|
22
34
|
if (!active) return ["Select a topping to see what it does."];
|
|
23
|
-
const url = `https://pi.dev/packages/${active.topping.pkg}`;
|
|
24
|
-
|
|
35
|
+
const url = `https://pi.dev/packages/${active.status.topping.pkg}`;
|
|
36
|
+
const action = values[activeItemId] === true
|
|
37
|
+
? `Checked: will ${active.action === "install" ? "install" : "uninstall"}`
|
|
38
|
+
: "Unchecked: no change";
|
|
39
|
+
return [active.status.topping.provides, "", hyperlink(url, url), "", action];
|
|
25
40
|
}
|
|
26
41
|
|
|
27
42
|
function errorMessage(error: unknown): string {
|
|
28
43
|
return error instanceof Error ? error.message : String(error);
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
function
|
|
46
|
+
function snippet(text: string): string {
|
|
47
|
+
return stripControlChars(text.trim()).slice(0, STDERR_SNIPPET_CHARS);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function buildReport(installed: string[], removed: string[], failed: FailedAction[]): string {
|
|
32
51
|
const lines: string[] = [];
|
|
33
|
-
if (
|
|
52
|
+
if (installed.length > 0) lines.push(`✓ Installed: ${installed.join(", ")}`);
|
|
53
|
+
if (removed.length > 0) lines.push(`✓ Removed: ${removed.join(", ")}`);
|
|
34
54
|
if (failed.length > 0) {
|
|
35
55
|
lines.push("✗ Failed:");
|
|
36
|
-
for (const failure of failed) lines.push(` ${failure.spec}: ${failure.error}`);
|
|
56
|
+
for (const failure of failed) lines.push(` ${failure.action} ${failure.spec}: ${failure.error}`);
|
|
37
57
|
}
|
|
38
|
-
if (
|
|
58
|
+
if (installed.length > 0 || removed.length > 0) {
|
|
39
59
|
lines.push("");
|
|
40
|
-
lines.push("Restart Pi afterwards to
|
|
60
|
+
lines.push("Restart Pi afterwards to apply the changes.");
|
|
41
61
|
}
|
|
42
62
|
return lines.join("\n");
|
|
43
63
|
}
|
|
44
64
|
|
|
45
|
-
async function
|
|
65
|
+
async function runPackageCommands(
|
|
46
66
|
ctx: ExtensionCommandContext,
|
|
47
67
|
selected: readonly ToppingStatus[],
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
runner: PiPackageRunner,
|
|
69
|
+
action: PackageAction,
|
|
70
|
+
): Promise<{ succeeded: string[]; failed: FailedAction[] }> {
|
|
50
71
|
const succeeded: string[] = [];
|
|
51
|
-
const failed:
|
|
72
|
+
const failed: FailedAction[] = [];
|
|
52
73
|
for (const { topping } of selected) {
|
|
53
74
|
const spec = `npm:${topping.pkg}`;
|
|
54
|
-
ctx.ui.notify(
|
|
75
|
+
ctx.ui.notify(`${action === "install" ? "Installing" : "Removing"} ${spec}…`, "info");
|
|
55
76
|
try {
|
|
56
|
-
const result = await
|
|
77
|
+
const result = await runner(spec, PI_COMMAND_TIMEOUT_MS);
|
|
57
78
|
if (result.code === 0) {
|
|
58
79
|
succeeded.push(spec);
|
|
59
80
|
} else {
|
|
60
|
-
failed.push({ spec, error:
|
|
81
|
+
failed.push({ action, spec, error: snippet(result.stderr || result.stdout || `exit ${result.code}`) });
|
|
61
82
|
}
|
|
62
83
|
} catch (error) {
|
|
63
|
-
failed.push({ spec, error:
|
|
84
|
+
failed.push({ action, spec, error: snippet(errorMessage(error)) });
|
|
64
85
|
}
|
|
65
86
|
}
|
|
66
87
|
return { succeeded, failed };
|
|
67
88
|
}
|
|
68
89
|
|
|
69
|
-
export function registerSetupCommand(
|
|
90
|
+
export function registerSetupCommand(
|
|
91
|
+
pi: ExtensionAPI,
|
|
92
|
+
install: PiPackageRunner = spawnPiInstall,
|
|
93
|
+
remove: PiPackageRunner = spawnPiRemove,
|
|
94
|
+
): void {
|
|
70
95
|
pi.registerCommand("topping-setup", {
|
|
71
|
-
description: "Install
|
|
96
|
+
description: "Install or uninstall Pi Topping sibling extensions or manage the missing-extension notice.",
|
|
72
97
|
handler: async (args, ctx) => {
|
|
73
98
|
const action = args.trim();
|
|
74
99
|
if (action === "disable-side-toppings-check" || action === "enable-side-toppings-check") {
|
|
@@ -96,41 +121,62 @@ export function registerSetupCommand(pi: ExtensionAPI, install: ToppingInstaller
|
|
|
96
121
|
return;
|
|
97
122
|
}
|
|
98
123
|
|
|
99
|
-
const
|
|
100
|
-
|
|
124
|
+
const statuses = detectToppings(pi);
|
|
125
|
+
const removable = statuses.filter((status) => status.packageSource !== undefined);
|
|
126
|
+
const missing = statuses.filter((status) => !status.active && status.packageSource === undefined);
|
|
127
|
+
if (missing.length === 0 && removable.length === 0) {
|
|
101
128
|
ctx.ui.notify("All Pi Topping extensions are already active in this session.", "info");
|
|
102
129
|
return;
|
|
103
130
|
}
|
|
104
131
|
|
|
132
|
+
const rows = new Map<string, SetupRow>();
|
|
133
|
+
for (const status of missing) rows.set(`install:${status.topping.pkg}`, { status, action: "install" });
|
|
134
|
+
for (const status of removable) rows.set(`remove:${status.topping.pkg}`, { status, action: "remove" });
|
|
135
|
+
const sections = [];
|
|
136
|
+
if (missing.length > 0) {
|
|
137
|
+
sections.push({
|
|
138
|
+
title: "Missing Toppings",
|
|
139
|
+
items: missing.map(({ topping, onDisk }) => ({
|
|
140
|
+
id: `install:${topping.pkg}`,
|
|
141
|
+
label: `${topping.pkg}${onDisk ? " (on disk, not active)" : ""}`,
|
|
142
|
+
value: true,
|
|
143
|
+
})),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (removable.length > 0) {
|
|
147
|
+
sections.push({
|
|
148
|
+
title: "Installed Toppings",
|
|
149
|
+
items: removable.map(({ topping, active }) => ({
|
|
150
|
+
id: `remove:${topping.pkg}`,
|
|
151
|
+
label: `${topping.pkg}${active ? " (active)" : " (installed, not active)"}`,
|
|
152
|
+
value: false,
|
|
153
|
+
})),
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
105
157
|
const result = await showMenu<Record<string, boolean>>(ctx, {
|
|
106
158
|
title: "Pi Topping: Setup",
|
|
107
|
-
sections
|
|
108
|
-
|
|
109
|
-
title: "Missing Toppings",
|
|
110
|
-
items: missing.map(({ topping, onDisk }) => ({
|
|
111
|
-
id: topping.pkg,
|
|
112
|
-
label: `${topping.pkg}${onDisk ? " (on disk, not active)" : ""}`,
|
|
113
|
-
value: true,
|
|
114
|
-
})),
|
|
115
|
-
},
|
|
116
|
-
],
|
|
117
|
-
hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ install", "esc cancel"],
|
|
159
|
+
sections,
|
|
160
|
+
hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ apply", "esc cancel"],
|
|
118
161
|
previewTitle: "Description",
|
|
119
|
-
preview: (
|
|
162
|
+
preview: (values, _elapsedMs, activeItemId) => ({ lines: renderDescription(rows, activeItemId, values) }),
|
|
120
163
|
});
|
|
121
164
|
if (!result.applied) {
|
|
122
165
|
ctx.ui.notify("Pi Topping setup cancelled.", "info");
|
|
123
166
|
return;
|
|
124
167
|
}
|
|
125
168
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
169
|
+
const removals = selectedRows(rows, result.values, "remove");
|
|
170
|
+
const installs = selectedRows(rows, result.values, "install");
|
|
171
|
+
if (removals.length === 0 && installs.length === 0) {
|
|
172
|
+
ctx.ui.notify("No Pi Topping changes selected.", "info");
|
|
129
173
|
return;
|
|
130
174
|
}
|
|
131
175
|
|
|
132
|
-
const
|
|
133
|
-
|
|
176
|
+
const removalResult = await runPackageCommands(ctx, removals, remove, "remove");
|
|
177
|
+
const installResult = await runPackageCommands(ctx, installs, install, "install");
|
|
178
|
+
const failed = [...removalResult.failed, ...installResult.failed];
|
|
179
|
+
ctx.ui.notify(buildReport(installResult.succeeded, removalResult.succeeded, failed), failed.length > 0 ? "warning" : "info");
|
|
134
180
|
},
|
|
135
181
|
});
|
|
136
182
|
}
|
package/src/toppings.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { getAgentDir, type ExtensionAPI, type
|
|
1
|
+
import { getAgentDir, type CustomEntry, type ExtensionAPI, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
|
|
2
3
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { isSetupCheckDisabled } from "./setup-check.ts";
|
|
@@ -15,6 +16,8 @@ export interface ToppingStatus {
|
|
|
15
16
|
readonly topping: Topping;
|
|
16
17
|
readonly active: boolean;
|
|
17
18
|
readonly onDisk: boolean;
|
|
19
|
+
/** Exact npm source recorded in Pi's user settings for this topping. */
|
|
20
|
+
readonly packageSource: string | undefined;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export const TOPPINGS: readonly Topping[] = [
|
|
@@ -42,6 +45,12 @@ export const TOPPINGS: readonly Topping[] = [
|
|
|
42
45
|
dir: "pi-topping-web-tools",
|
|
43
46
|
provides: "web research and browser automation",
|
|
44
47
|
},
|
|
48
|
+
{
|
|
49
|
+
pkg: "@underactive/pi-topping-moa-fusion",
|
|
50
|
+
matches: /pi-topping-moa-fusion/i,
|
|
51
|
+
dir: "pi-topping-moa-fusion",
|
|
52
|
+
provides: "Mixture-of-Agents Fusion plan mode",
|
|
53
|
+
},
|
|
45
54
|
];
|
|
46
55
|
|
|
47
56
|
interface PiSettings {
|
|
@@ -51,16 +60,39 @@ interface PiSettings {
|
|
|
51
60
|
|
|
52
61
|
interface SettingsToppingState {
|
|
53
62
|
packageInstalled: boolean;
|
|
63
|
+
packageSource?: string;
|
|
54
64
|
extensionPresent: boolean;
|
|
55
65
|
extensionActive: boolean;
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
export const SETUP_ENTRY_TYPE = "pi-topping-setup";
|
|
69
|
+
|
|
70
|
+
export interface MissingToppingsEntryData {
|
|
71
|
+
readonly toppings: ReadonlyArray<Pick<Topping, "pkg" | "provides">>;
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
let setupNoticeHandled = false;
|
|
59
75
|
|
|
60
76
|
function stringEntries(value: unknown): string[] {
|
|
61
77
|
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
62
78
|
}
|
|
63
79
|
|
|
80
|
+
function sourceEntries(value: unknown): string[] {
|
|
81
|
+
if (!Array.isArray(value)) return [];
|
|
82
|
+
return value.flatMap((entry) => {
|
|
83
|
+
if (typeof entry === "string") return [entry];
|
|
84
|
+
return isPlainObject(entry) && typeof entry.source === "string" ? [entry.source] : [];
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function npmPackageName(source: string): string | undefined {
|
|
89
|
+
const value = source.trim();
|
|
90
|
+
if (!value.startsWith("npm:")) return undefined;
|
|
91
|
+
const spec = value.slice("npm:".length);
|
|
92
|
+
const versionIndex = spec.lastIndexOf("@");
|
|
93
|
+
return versionIndex > 0 ? spec.slice(0, versionIndex) : spec;
|
|
94
|
+
}
|
|
95
|
+
|
|
64
96
|
function readPiSettings(): PiSettings | undefined {
|
|
65
97
|
try {
|
|
66
98
|
const parsed: unknown = JSON.parse(readFileSync(join(getAgentDir(), "settings.json"), "utf8"));
|
|
@@ -78,10 +110,12 @@ function extensionEntry(entry: string): { value: string; disabled: boolean } {
|
|
|
78
110
|
|
|
79
111
|
function settingsState(settings: PiSettings | undefined, topping: Topping): SettingsToppingState {
|
|
80
112
|
let packageInstalled = false;
|
|
113
|
+
let packageSource: string | undefined;
|
|
81
114
|
let extensionPresent = false;
|
|
82
115
|
let extensionActive = false;
|
|
83
|
-
for (const entry of
|
|
116
|
+
for (const entry of sourceEntries(settings?.packages)) {
|
|
84
117
|
if (topping.matches.test(entry)) packageInstalled = true;
|
|
118
|
+
if (npmPackageName(entry) === topping.pkg) packageSource = entry;
|
|
85
119
|
}
|
|
86
120
|
for (const entry of stringEntries(settings?.extensions)) {
|
|
87
121
|
const normalized = extensionEntry(entry);
|
|
@@ -89,7 +123,7 @@ function settingsState(settings: PiSettings | undefined, topping: Topping): Sett
|
|
|
89
123
|
extensionPresent = true;
|
|
90
124
|
if (!normalized.disabled) extensionActive = true;
|
|
91
125
|
}
|
|
92
|
-
return { packageInstalled, extensionPresent, extensionActive };
|
|
126
|
+
return { packageInstalled, packageSource, extensionPresent, extensionActive };
|
|
93
127
|
}
|
|
94
128
|
|
|
95
129
|
function runtimeActiveToppings(pi: ExtensionAPI): Array<true | undefined> | undefined {
|
|
@@ -117,7 +151,7 @@ export function detectToppings(pi: ExtensionAPI): ToppingStatus[] {
|
|
|
117
151
|
const checkoutPresent = existsSync(join(extensionsDir, topping.dir));
|
|
118
152
|
const onDisk = configured.packageInstalled || configured.extensionPresent || checkoutPresent;
|
|
119
153
|
const fallbackActive = configured.packageInstalled || configured.extensionActive || checkoutPresent;
|
|
120
|
-
return { topping, active: runtime?.[index] ?? fallbackActive, onDisk };
|
|
154
|
+
return { topping, active: runtime?.[index] ?? fallbackActive, onDisk, packageSource: configured.packageSource };
|
|
121
155
|
});
|
|
122
156
|
}
|
|
123
157
|
|
|
@@ -125,31 +159,60 @@ export function findMissingToppings(pi: ExtensionAPI): ToppingStatus[] {
|
|
|
125
159
|
return detectToppings(pi).filter((status) => !status.active);
|
|
126
160
|
}
|
|
127
161
|
|
|
128
|
-
function
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
162
|
+
function renderMissingToppings(
|
|
163
|
+
toppings: MissingToppingsEntryData["toppings"],
|
|
164
|
+
theme: Pick<Theme, "fg">,
|
|
165
|
+
width: number,
|
|
166
|
+
): string[] {
|
|
167
|
+
const safeWidth = Math.max(0, width);
|
|
168
|
+
const text = (value: string): string => theme.fg("text", value);
|
|
169
|
+
const accent = (value: string): string => theme.fg("accent", value);
|
|
170
|
+
const fit = (line: string): string => truncateToWidth(line, safeWidth, "");
|
|
171
|
+
const rule = theme.fg("warning", "─".repeat(safeWidth));
|
|
172
|
+
return [
|
|
173
|
+
rule,
|
|
174
|
+
fit(text(`pi-topping: ${toppings.length} topping extension${toppings.length === 1 ? "" : "s"} missing`)),
|
|
175
|
+
"",
|
|
176
|
+
...toppings.map(({ pkg, provides }) => fit(`${text("• ")}${accent(pkg)}${text(` — ${provides}`)}`)),
|
|
132
177
|
"",
|
|
133
|
-
"Run
|
|
134
|
-
"Run
|
|
178
|
+
fit(`${text("Run ")}${accent("/topping-setup")}${text(" to selectively install them")}`),
|
|
179
|
+
fit(`${text("Run ")}${accent("/topping-setup disable-side-toppings-check")}${text(" to suppress this message")}`),
|
|
180
|
+
rule,
|
|
135
181
|
];
|
|
136
|
-
const width = Math.max(title.length + 6, ...body.map((line) => line.length + 4));
|
|
137
|
-
const blankRow = `│${" ".repeat(width - 2)}│`;
|
|
138
|
-
return [
|
|
139
|
-
`╭─ ${title} ${"─".repeat(width - title.length - 5)}╮`,
|
|
140
|
-
blankRow,
|
|
141
|
-
...body.map((line) => `│ ${line.padEnd(width - 4)} │`),
|
|
142
|
-
blankRow,
|
|
143
|
-
`╰${"─".repeat(width - 2)}╯`,
|
|
144
|
-
].join("\n");
|
|
145
182
|
}
|
|
146
183
|
|
|
147
|
-
export function
|
|
184
|
+
export function renderMissingToppingsEntry(
|
|
185
|
+
entry: CustomEntry<MissingToppingsEntryData>,
|
|
186
|
+
theme: Theme,
|
|
187
|
+
): Component | undefined {
|
|
188
|
+
if (!entry.data || !Array.isArray(entry.data.toppings)) return undefined;
|
|
189
|
+
let cachedWidth: number | undefined;
|
|
190
|
+
let cachedLines: string[] | undefined;
|
|
191
|
+
return {
|
|
192
|
+
render(width: number): string[] {
|
|
193
|
+
if (cachedWidth !== width) {
|
|
194
|
+
cachedWidth = width;
|
|
195
|
+
cachedLines = renderMissingToppings(entry.data!.toppings, theme, width);
|
|
196
|
+
}
|
|
197
|
+
return cachedLines!;
|
|
198
|
+
},
|
|
199
|
+
invalidate(): void {
|
|
200
|
+
cachedWidth = undefined;
|
|
201
|
+
cachedLines = undefined;
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function announceMissingToppingsOnce(pi: ExtensionAPI): void {
|
|
148
207
|
if (setupNoticeHandled) return;
|
|
149
208
|
setupNoticeHandled = true;
|
|
150
209
|
if (isSetupCheckDisabled()) return;
|
|
151
210
|
const missing = findMissingToppings(pi);
|
|
152
|
-
if (missing.length > 0)
|
|
211
|
+
if (missing.length > 0) {
|
|
212
|
+
pi.appendEntry<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, {
|
|
213
|
+
toppings: missing.map(({ topping }) => ({ pkg: topping.pkg, provides: topping.provides })),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
153
216
|
}
|
|
154
217
|
|
|
155
218
|
/** Test-only reset for the once-per-process session-start notice. */
|