@underactive/pi-topping 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -10
- package/package.json +3 -3
- package/src/output-style.ts +0 -28
- package/src/prompt-decorator.ts +23 -6
- package/src/session.ts +84 -60
- package/src/util.ts +40 -0
package/README.md
CHANGED
|
@@ -2,15 +2,15 @@
|
|
|
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. The box, timestamp, and provider/model label
|
|
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 do not change the prompt Pi sends to the model, so they do not affect LLM prompt caching. Explicit `/skill:name` invocations also get a display-only box while Pi expands the skill natively; Pi's `[skill]` entry remains visible beside it. 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
|
|
|
9
|
-
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection. Under a Pi virtual model such as pi-topping-routesense's `routesense/auto`, the response model is the routed physical model. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
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. It can also append per-call service-tier counts (e.g. `flex tier ×3`), but only for calls whose provider reports a tier
|
|
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. It can also append per-call service-tier counts (e.g. `flex tier ×3`), but only for calls whose provider reports a tier. 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. A cancelled run (for example, one stopped with Escape) shows `Interrupted` instead of the random verb, with the word and any border in the warning color; time, tokens, and other enabled details still appear. **Show completion marker** also controls this marker.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -167,14 +167,12 @@ restarts the cycle.
|
|
|
167
167
|
|
|
168
168
|
The token activity monitor reaches full `⣿` at or above its configurable upper bound (80 tps by default), with lower levels scaled proportionally to that bound. In `/topping-settings`, `←`/`→` cycle through presets spanning 10–1000 tps, and `␣` (Space) lets you enter any whole number from 10–1000. Invalid typed entries show an inline error and keep the editor open without changing the current value; invalid hand-edited `meterUpperBoundTps` values in `settings.json` revert to 80 on load.
|
|
169
169
|
|
|
170
|
-
The response model is captured from assistant responses and raw Responses-API stream events
|
|
170
|
+
The response model is captured from assistant responses and raw Responses-API stream events, 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. When Ramp Router reports a gateway backend in the raw event, the label can include it as `<model> via <backend>`, or `via <backend>` alone when the served model restates the selection. Under a Pi virtual model such as pi-topping-routesense's `routesense/auto`, it is the routed physical model: taken from the router's `pi-topping-routesense:routed-model` announcement when present, which arrives before the first token (announcements for `direct` routes such as compaction are ignored), and otherwise from the assistant message's model. There only a failed route, which reports the virtual model itself, is hidden, and a bare routed id never displaces a provider-reported decorated form of itself such as a dated name. 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, as is the selected model's id or display name written with other case and separators; trailing parenthesized notes in the display name, such as `(Local)`, are ignored. 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.
|
|
171
171
|
|
|
172
172
|
## Requirements
|
|
173
173
|
|
|
174
|
-
- [Pi](https://github.com/earendil-works/pi-coding-agent)
|
|
175
|
-
-
|
|
176
|
-
- Pi ≥1.1.0 shows `Interrupted` for cancelled runs; older versions show the normal completion marker.
|
|
177
|
-
- On Pi ≥0.85.0, the working indicator is embedded in the default editor border and long loaders are truncated to the border width instead of wrapping; custom editors that do not opt in keep the standalone wrapping indicator.
|
|
174
|
+
- [Pi](https://github.com/earendil-works/pi-coding-agent) ≥1.2.0. Output styles rely on Pi running the agent-start hook for the run that the decorated prompt starts, which Pi 1.2.0 added. On an older host pi-topping shows a one-time warning at session start, and output styles do not reach decorated prompts.
|
|
175
|
+
- The working indicator is embedded in the default editor border and long loaders are truncated to the border width instead of wrapping; custom editors that do not opt in keep the standalone wrapping indicator.
|
|
178
176
|
- Node.js ≥22.19.0
|
|
179
177
|
|
|
180
178
|
## Development
|
|
@@ -192,8 +190,7 @@ Tests cover the word/shimmer rendering, prompt box and completion marker renderi
|
|
|
192
190
|
- Token counts are a streaming word-count estimate, not an exact tokenizer count — they self-correct when each message finishes, so you'll see a small jump at that point.
|
|
193
191
|
- Elapsed time normally starts when you submit a prompt; for auto-continuations without that event, it starts slightly later instead.
|
|
194
192
|
- If another extension also customizes the working message, whichever one runs last wins.
|
|
195
|
-
- Service-tier details and Responses-API served-model/backend labels depend on Pi's `provider_stream_event`;
|
|
196
|
-
- Output styles have no effect on hosts without structured system-prompt sections (see Requirements).
|
|
193
|
+
- Service-tier details and Responses-API served-model/backend labels depend on Pi's `provider_stream_event`; SDK adapters that do not expose raw events show neither.
|
|
197
194
|
|
|
198
195
|
## Author
|
|
199
196
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, an end-of-turn completion marker, and selectable output styles",
|
|
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": "1.
|
|
34
|
-
"@earendil-works/pi-tui": "1.
|
|
33
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
34
|
+
"@earendil-works/pi-tui": "1.3.0",
|
|
35
35
|
"@types/node": "^24.0.0",
|
|
36
36
|
"typescript": "^5.9.3"
|
|
37
37
|
},
|
package/src/output-style.ts
CHANGED
|
@@ -170,31 +170,3 @@ export function applyOutputStyle(
|
|
|
170
170
|
},
|
|
171
171
|
};
|
|
172
172
|
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* Section value to send when the transcript's folded `output_style` text is `current`.
|
|
176
|
-
* `null` removes the section. `undefined` means the transcript already matches `id`.
|
|
177
|
-
*/
|
|
178
|
-
export function outputStyleSectionPatch(current: string | undefined, id: OutputStyleId): string | null | undefined {
|
|
179
|
-
const sections: Record<string, string> = {};
|
|
180
|
-
if (current !== undefined) sections[OUTPUT_STYLE_SECTION] = current;
|
|
181
|
-
applyOutputStyle(sections, id);
|
|
182
|
-
const next = sections[OUTPUT_STYLE_SECTION];
|
|
183
|
-
if (next === current) return undefined;
|
|
184
|
-
return next ?? null;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/** Last `output_style` section after replaying system messages. A `null` value clears it. */
|
|
188
|
-
export function foldedOutputStyleSection(
|
|
189
|
-
messages: readonly { role: string; sections?: Record<string, string | null> }[],
|
|
190
|
-
): string | undefined {
|
|
191
|
-
let current: string | undefined;
|
|
192
|
-
for (const message of messages) {
|
|
193
|
-
if (message.role !== "system") continue;
|
|
194
|
-
const sections = message.sections;
|
|
195
|
-
if (!sections || !Object.prototype.hasOwnProperty.call(sections, OUTPUT_STYLE_SECTION)) continue;
|
|
196
|
-
const value = sections[OUTPUT_STYLE_SECTION];
|
|
197
|
-
current = value === null ? undefined : value;
|
|
198
|
-
}
|
|
199
|
-
return current;
|
|
200
|
-
}
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type { MessageRenderer, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
1
|
+
import type { EntryRenderer, MessageRenderer, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
|
|
3
3
|
import { getThinkingLevelColorizer, isThinkingLevel, type ThinkingLevel } from "./format.ts";
|
|
4
4
|
import { DEFAULT_SETTINGS, isBorderStyle, isPromptBorderColor, type BorderStyle, type DoneMarkerBorderColor, type DoneMarkerBorderStyle, type DoneMarkerStyle, type PromptBorderColor } from "./settings.ts";
|
|
5
5
|
import { stripControlChars } from "./util.ts";
|
|
6
6
|
|
|
7
7
|
export const PROMPT_BOX_TYPE = "pi-topping-prompt";
|
|
8
|
+
export const PROMPT_BOX_ENTRY_TYPE = "pi-topping-prompt-entry";
|
|
8
9
|
|
|
9
10
|
const BORDER_GLYPHS: Record<BorderStyle, { tl: string; tr: string; bl: string; br: string; h: string; v: string }> = {
|
|
10
11
|
double: { tl: "╔", tr: "╗", bl: "╚", br: "╝", h: "═", v: "║" },
|
|
@@ -34,6 +35,11 @@ export interface PromptBoxDetails {
|
|
|
34
35
|
thinkingLevel?: ThinkingLevel;
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
export interface PromptBoxEntryData {
|
|
39
|
+
content: string;
|
|
40
|
+
details: PromptBoxDetails;
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
type PromptTheme = {
|
|
38
44
|
fg(color: string, text: string): string;
|
|
39
45
|
getThinkingBorderColor(level: ThinkingLevel): (text: string) => string;
|
|
@@ -150,16 +156,15 @@ export function buildPromptBoxLines(
|
|
|
150
156
|
return lines;
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
const details = (message.details ?? {}) as PromptBoxDetails;
|
|
159
|
+
function createPromptBoxComponent(content: string, details: PromptBoxDetails, theme: Theme): Component {
|
|
160
|
+
const safeContent = content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "");
|
|
156
161
|
let cachedWidth: number | undefined;
|
|
157
162
|
let cachedLines: string[] | undefined;
|
|
158
163
|
return {
|
|
159
164
|
render(width: number): string[] {
|
|
160
165
|
if (cachedWidth !== width) {
|
|
161
166
|
cachedWidth = width;
|
|
162
|
-
cachedLines = buildPromptBoxLines(
|
|
167
|
+
cachedLines = buildPromptBoxLines(safeContent, details.submittedAt, width, theme, details);
|
|
163
168
|
}
|
|
164
169
|
return cachedLines!;
|
|
165
170
|
},
|
|
@@ -168,4 +173,16 @@ export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _o
|
|
|
168
173
|
cachedLines = undefined;
|
|
169
174
|
},
|
|
170
175
|
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
|
|
179
|
+
const content = typeof message.content === "string" ? message.content : "";
|
|
180
|
+
const details = (message.details ?? {}) as PromptBoxDetails;
|
|
181
|
+
return createPromptBoxComponent(content, details, theme);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const promptBoxEntryRenderer: EntryRenderer<PromptBoxEntryData> = (entry, _options, theme) => {
|
|
185
|
+
const data = entry.data;
|
|
186
|
+
if (!data || typeof data.content !== "string") return undefined;
|
|
187
|
+
return createPromptBoxComponent(data.content, data.details ?? {}, theme);
|
|
171
188
|
};
|
package/src/session.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
SessionStartEvent,
|
|
15
15
|
Theme,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
17
18
|
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
18
19
|
import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
|
|
19
20
|
import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./routesense-feed.ts";
|
|
@@ -42,15 +43,24 @@ import {
|
|
|
42
43
|
} from "./format.ts";
|
|
43
44
|
import { buildLoaderMessage } from "./loader-message.ts";
|
|
44
45
|
import { showMenu } from "./menu.ts";
|
|
45
|
-
import { applyOutputStyle,
|
|
46
|
+
import { applyOutputStyle, OUTPUT_STYLES } from "./output-style.ts";
|
|
46
47
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
47
48
|
import { registerSetupCommand } from "./setup-command.ts";
|
|
48
49
|
import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
|
|
49
50
|
import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.ts";
|
|
50
51
|
import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
|
|
51
52
|
import { PreviewRenderer } from "./preview.ts";
|
|
52
|
-
import {
|
|
53
|
-
|
|
53
|
+
import {
|
|
54
|
+
buildCompletionMarkerContent,
|
|
55
|
+
buildCompletionMarkerLine,
|
|
56
|
+
PROMPT_BOX_ENTRY_TYPE,
|
|
57
|
+
PROMPT_BOX_TYPE,
|
|
58
|
+
promptBoxEntryRenderer,
|
|
59
|
+
promptBoxRenderer,
|
|
60
|
+
type PromptBoxDetails,
|
|
61
|
+
type PromptBoxEntryData,
|
|
62
|
+
} from "./prompt-decorator.ts";
|
|
63
|
+
import { hostBelow, isPlainObject, modelsResemble, modelsSpelledAlike, sanitizedText, stripControlChars } from "./util.ts";
|
|
54
64
|
import { formatServiceTierSummary, recordServiceTierCall, serviceTierCountsOf, serviceTierOf, type ServiceTierCount } from "./service-tier.ts";
|
|
55
65
|
import { servedModelOf } from "./served-model.ts";
|
|
56
66
|
import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
|
|
@@ -61,10 +71,14 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
|
|
|
61
71
|
type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
|
|
62
72
|
type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
|
|
63
73
|
type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
|
|
64
|
-
type ContextWithSystemEvent = Extract<ExtensionEvent, { type: "context_with_system" }>;
|
|
65
74
|
type ProviderStreamEvent = Extract<ExtensionEvent, { type: "provider_stream_event" }>;
|
|
66
75
|
type UIPromptKind = UIPromptStartEvent["kind"];
|
|
67
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Oldest Pi this extension supports. Pi 1.2.0 runs before_agent_start for the run that a custom
|
|
79
|
+
* message starts, which is how the output style reaches a decorated prompt.
|
|
80
|
+
*/
|
|
81
|
+
export const MIN_HOST_VERSION = "1.2.0";
|
|
68
82
|
const DONE_ENTRY_TYPE = "pi-topping-done";
|
|
69
83
|
const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
|
|
70
84
|
/** Pi's `VIRTUAL_MODEL_API` for extension-registered virtual models; Pi does not export it from its public index. */
|
|
@@ -203,10 +217,14 @@ export class SessionManager {
|
|
|
203
217
|
#currentCtx: ExtensionContext | null = null;
|
|
204
218
|
/** Whether pi-topping-statusline last announced that its status bar hosts the loader; kept across sessions. */
|
|
205
219
|
#loaderInStatusline = false;
|
|
220
|
+
/** Whether this instance showed the old-host warning. */
|
|
221
|
+
#warnedOldHost = false;
|
|
206
222
|
readonly #pi: ExtensionAPI;
|
|
223
|
+
readonly #hostVersion: string;
|
|
207
224
|
|
|
208
|
-
constructor(pi: ExtensionAPI) {
|
|
225
|
+
constructor(pi: ExtensionAPI, hostVersion: string = VERSION) {
|
|
209
226
|
this.#pi = pi;
|
|
227
|
+
this.#hostVersion = hostVersion;
|
|
210
228
|
}
|
|
211
229
|
|
|
212
230
|
#onSessionStart = async (_e: SessionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
@@ -223,37 +241,20 @@ export class SessionManager {
|
|
|
223
241
|
if (this.usable(ctx)) {
|
|
224
242
|
this.applyIndicator(ctx);
|
|
225
243
|
announceMissingToppingsOnce(this.#pi);
|
|
244
|
+
this.warnOldHostOnce(ctx);
|
|
226
245
|
}
|
|
227
246
|
};
|
|
228
247
|
|
|
229
|
-
/** Unlike the UI handlers, this runs in every mode: the output style shapes the model's prompt. */
|
|
230
|
-
#onBeforeAgentStart = (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined =>
|
|
231
|
-
applyOutputStyle(event.systemPromptOptions?.sections, this.#settings.outputStyle);
|
|
232
|
-
|
|
233
248
|
/**
|
|
234
|
-
*
|
|
235
|
-
*
|
|
249
|
+
* Unlike the UI handlers, this runs in every mode: the output style shapes the model's prompt.
|
|
250
|
+
* The prompt section applies to every run. The hidden reminder belongs to a user prompt: one Pi
|
|
251
|
+
* submitted (`messageType: "user"`), or one that pi-topping re-sent as its prompt box, which
|
|
252
|
+
* starts the run as a custom message. A run that another extension's custom message starts gets
|
|
253
|
+
* the section only.
|
|
236
254
|
*/
|
|
237
|
-
#
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
const messages = event.messages.slice();
|
|
241
|
-
let index = messages.length;
|
|
242
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
243
|
-
const message = messages[i];
|
|
244
|
-
if (!message) continue;
|
|
245
|
-
if (message.role === "user" || (message.role === "custom" && message.customType === PROMPT_BOX_TYPE)) {
|
|
246
|
-
index = i;
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
messages.splice(index, 0, {
|
|
251
|
-
role: "system",
|
|
252
|
-
content: "",
|
|
253
|
-
sections: { [OUTPUT_STYLE_SECTION]: patch },
|
|
254
|
-
timestamp: Date.now(),
|
|
255
|
-
});
|
|
256
|
-
return { messages };
|
|
255
|
+
#onBeforeAgentStart = (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined => {
|
|
256
|
+
const result = applyOutputStyle(event.systemPromptOptions?.sections, this.#settings.outputStyle);
|
|
257
|
+
return event.messageType !== "custom" || event.message.customType === PROMPT_BOX_TYPE ? result : undefined;
|
|
257
258
|
};
|
|
258
259
|
|
|
259
260
|
#onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
|
|
@@ -262,40 +263,24 @@ export class SessionManager {
|
|
|
262
263
|
|
|
263
264
|
if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
|
|
264
265
|
else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
|
|
266
|
+
if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorateSkill(event)) {
|
|
267
|
+
this.#pi.appendEntry<PromptBoxEntryData>(PROMPT_BOX_ENTRY_TYPE, {
|
|
268
|
+
content: event.text,
|
|
269
|
+
details: this.promptBoxDetails(ctx),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
265
272
|
if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
|
|
266
273
|
try {
|
|
267
|
-
|
|
268
|
-
|
|
274
|
+
// The prompt box starts the run; before_agent_start adds the output style's section and reminder to it.
|
|
275
|
+
this.#pi.sendMessage<PromptBoxDetails>(
|
|
269
276
|
{
|
|
270
277
|
customType: PROMPT_BOX_TYPE,
|
|
271
278
|
content: event.text,
|
|
272
279
|
display: true,
|
|
273
|
-
details:
|
|
274
|
-
submittedAt: Date.now(),
|
|
275
|
-
showIcon: this.#settings.decorations.promptIcon,
|
|
276
|
-
showTimestamp: this.#settings.decorations.promptTimestamp,
|
|
277
|
-
showProvider: this.#settings.decorations.promptProvider,
|
|
278
|
-
showModel: this.#settings.decorations.promptModel,
|
|
279
|
-
icon: this.#settings.decorations.useNerdFont ? "" : "π",
|
|
280
|
-
provider: ctx.model?.provider,
|
|
281
|
-
model: ctx.model?.id,
|
|
282
|
-
borderColor: this.#settings.decorations.borderColor,
|
|
283
|
-
borderStyle: this.#settings.decorations.borderStyle,
|
|
284
|
-
thinkingLevel: ctx.thinkingLevel,
|
|
285
|
-
},
|
|
280
|
+
details: this.promptBoxDetails(ctx),
|
|
286
281
|
},
|
|
287
|
-
{ triggerTurn:
|
|
282
|
+
{ triggerTurn: true },
|
|
288
283
|
);
|
|
289
|
-
if (stylePrompt) {
|
|
290
|
-
await this.#pi.sendMessage(
|
|
291
|
-
{
|
|
292
|
-
customType: OUTPUT_STYLE_MESSAGE_TYPE,
|
|
293
|
-
content: stylePrompt.reminder,
|
|
294
|
-
display: false,
|
|
295
|
-
},
|
|
296
|
-
{ triggerTurn: true },
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
284
|
return { action: "handled" };
|
|
300
285
|
} catch {
|
|
301
286
|
ctx.ui.notify("Failed to submit prompt", "error");
|
|
@@ -495,7 +480,6 @@ export class SessionManager {
|
|
|
495
480
|
install(): void {
|
|
496
481
|
this.#pi.on("session_start", this.#onSessionStart);
|
|
497
482
|
this.#pi.on("before_agent_start", this.#onBeforeAgentStart);
|
|
498
|
-
this.#pi.on("context_with_system", this.#onContextWithSystem);
|
|
499
483
|
this.#pi.on("input", this.#onInput);
|
|
500
484
|
this.#pi.on("agent_start", this.#onAgentStart);
|
|
501
485
|
this.#pi.on("message_start", this.#onMessageStart);
|
|
@@ -519,6 +503,7 @@ export class SessionManager {
|
|
|
519
503
|
});
|
|
520
504
|
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
|
|
521
505
|
this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
|
|
506
|
+
this.#pi.registerEntryRenderer<PromptBoxEntryData>(PROMPT_BOX_ENTRY_TYPE, promptBoxEntryRenderer);
|
|
522
507
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
523
508
|
this.#pi.registerCommand("topping-settings", {
|
|
524
509
|
description: "Configure the prompt box, working loader, completion marker, word packs, and output style.",
|
|
@@ -531,11 +516,49 @@ export class SessionManager {
|
|
|
531
516
|
return !!ctx.hasUI && ctx.mode !== "print";
|
|
532
517
|
}
|
|
533
518
|
|
|
519
|
+
private warnOldHostOnce(ctx: ExtensionContext): void {
|
|
520
|
+
if (this.#warnedOldHost || !hostBelow(this.#hostVersion, MIN_HOST_VERSION)) return;
|
|
521
|
+
this.#warnedOldHost = true;
|
|
522
|
+
ctx.ui.notify(
|
|
523
|
+
`Pi Topping needs Pi ${MIN_HOST_VERSION} or later; this is Pi ${this.#hostVersion}. Output styles will not apply to decorated prompts.`,
|
|
524
|
+
"warning",
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
534
528
|
/**
|
|
535
529
|
* Mid-stream inputs (steer/follow-up) must pass through untouched: re-sending them via
|
|
536
530
|
* sendMessage(deliverAs) bypasses Pi's queue state, so the "Steering:"/"Follow-up:" rows
|
|
537
531
|
* below the editor never render (issue #2).
|
|
538
532
|
*/
|
|
533
|
+
private promptBoxDetails(ctx: ExtensionContext): PromptBoxDetails {
|
|
534
|
+
return {
|
|
535
|
+
submittedAt: Date.now(),
|
|
536
|
+
showIcon: this.#settings.decorations.promptIcon,
|
|
537
|
+
showTimestamp: this.#settings.decorations.promptTimestamp,
|
|
538
|
+
showProvider: this.#settings.decorations.promptProvider,
|
|
539
|
+
showModel: this.#settings.decorations.promptModel,
|
|
540
|
+
icon: this.#settings.decorations.useNerdFont ? "" : "π",
|
|
541
|
+
provider: ctx.model?.provider,
|
|
542
|
+
model: ctx.model?.id,
|
|
543
|
+
borderColor: this.#settings.decorations.borderColor,
|
|
544
|
+
borderStyle: this.#settings.decorations.borderStyle,
|
|
545
|
+
thinkingLevel: ctx.thinkingLevel,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private shouldDecorateSkill(event: InputEvent): boolean {
|
|
550
|
+
if (
|
|
551
|
+
event.streamingBehavior ||
|
|
552
|
+
event.source === "extension" ||
|
|
553
|
+
!event.text.trim() ||
|
|
554
|
+
event.images?.length ||
|
|
555
|
+
!event.text.startsWith("/")
|
|
556
|
+
) return false;
|
|
557
|
+
|
|
558
|
+
const commandName = event.text.slice(1).split(" ", 1)[0]!;
|
|
559
|
+
return this.#pi.getCommands().some((command) => command.source === "skill" && command.name === commandName);
|
|
560
|
+
}
|
|
561
|
+
|
|
539
562
|
private shouldDecorate(event: InputEvent): boolean {
|
|
540
563
|
return (
|
|
541
564
|
!event.streamingBehavior &&
|
|
@@ -600,7 +623,8 @@ export class SessionManager {
|
|
|
600
623
|
}
|
|
601
624
|
|
|
602
625
|
/**
|
|
603
|
-
* Whether a response model merely restates the selected model
|
|
626
|
+
* Whether a response model merely restates the selected model: a decorated form of its id,
|
|
627
|
+
* or its id or display name written with other case and separators. Under a virtual selection
|
|
604
628
|
* the routed model is always another model, so only a failed route, which leaves the
|
|
605
629
|
* virtual model itself on the message, is hidden; a resemblance check would also hide a
|
|
606
630
|
* routed model whose id contains the virtual id, such as openrouter's `auto`.
|
|
@@ -609,7 +633,7 @@ export class SessionManager {
|
|
|
609
633
|
const selected = this.#currentCtx?.model;
|
|
610
634
|
if (!selected) return false;
|
|
611
635
|
if (selected.api === VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
|
|
612
|
-
return modelsResemble(selected.id, responseModel);
|
|
636
|
+
return modelsResemble(selected.id, responseModel) || modelsSpelledAlike(selected, responseModel);
|
|
613
637
|
}
|
|
614
638
|
|
|
615
639
|
private updateResponseModel(report: ResponseModelReport | undefined): void {
|
package/src/util.ts
CHANGED
|
@@ -12,6 +12,25 @@ export function sanitizedText(value: unknown): string {
|
|
|
12
12
|
return typeof value === "string" ? stripControlChars(value).trim() : "";
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* True when `version` is a release older than `minimum`, both read as `major.minor.patch`.
|
|
17
|
+
* A value that does not start with three numbers cannot be compared and counts as new enough;
|
|
18
|
+
* a pre-release suffix is ignored.
|
|
19
|
+
*/
|
|
20
|
+
export function hostBelow(version: string, minimum: string): boolean {
|
|
21
|
+
const parse = (text: string): number[] | undefined => {
|
|
22
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(text.trim());
|
|
23
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined;
|
|
24
|
+
};
|
|
25
|
+
const actual = parse(version);
|
|
26
|
+
const floor = parse(minimum);
|
|
27
|
+
if (!actual || !floor) return false;
|
|
28
|
+
for (let i = 0; i < 3; i++) {
|
|
29
|
+
if (actual[i] !== floor[i]) return actual[i]! < floor[i]!;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
15
34
|
/** True when a reported response model appears to be a decorated form of the selected model. */
|
|
16
35
|
export function modelsResemble(selectedModel?: string, responseModel?: string): boolean {
|
|
17
36
|
if (!selectedModel || !responseModel) return false;
|
|
@@ -20,3 +39,24 @@ export function modelsResemble(selectedModel?: string, responseModel?: string):
|
|
|
20
39
|
const normalizedResponse = responseModel.toLowerCase();
|
|
21
40
|
return selectedTokens.length > 0 && selectedTokens.every((token) => normalizedResponse.includes(token));
|
|
22
41
|
}
|
|
42
|
+
|
|
43
|
+
/** Lowercase letters and digits only, so names that differ just in case and separators compare equal. */
|
|
44
|
+
function spelling(text: string): string {
|
|
45
|
+
return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when a reported response model is the selected model's id, or its display name with or
|
|
50
|
+
* without trailing parenthesized notes, written with other case and separators: a model named
|
|
51
|
+
* `Qwen3.8 Flash (Local)` matches `qwen3.8-flash`. Every digit counts, so `qwen3-flash` does not,
|
|
52
|
+
* and nothing else may differ, so an id with a suffix such as `256k` does not match a response without it.
|
|
53
|
+
*/
|
|
54
|
+
export function modelsSpelledAlike(selected: { id: string; name?: string }, responseModel?: string): boolean {
|
|
55
|
+
if (!responseModel) return false;
|
|
56
|
+
const lastSegment = (model: string): string => model.split(/[\\/]/).at(-1) ?? "";
|
|
57
|
+
const response = spelling(lastSegment(responseModel));
|
|
58
|
+
if (!response) return false;
|
|
59
|
+
const name = selected.name ?? "";
|
|
60
|
+
const candidates = [lastSegment(selected.id), name, name.replace(/(?:\s*\([^()]*\))+\s*$/, "")];
|
|
61
|
+
return candidates.some((candidate) => spelling(candidate) === response);
|
|
62
|
+
}
|