@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 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 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.
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
  ![Example of pi-topping's decorated user prompt](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_user_prompt.png)
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 (requires Pi ≥0.99.0). 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.
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
  ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, token count, and token rate](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo.gif)
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; this requires Pi ≥0.99.0. 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. On Pi ≥1.1.0, 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. Older Pi versions show the normal marker for cancelled runs.
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
  ![Example of pi-topping's completion marker](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo_completion_marker.png)
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 (Pi ≥0.99.0), 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. 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.
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). Pi ≥0.84.4 is required for the waiting-for-input loader state; older Pi versions keep the animated loader.
175
- - Output styles need a Pi version that exposes structured system-prompt sections (tested on Pi 1.0.3 and 1.1.0); on older hosts the style setting has no effect.
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`; raw events require Pi ≥0.99.0 and are unavailable on older Pi hosts and SDK adapters that do not expose them.
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.11.1",
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.1.0",
34
- "@earendil-works/pi-tui": "1.1.0",
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
  },
@@ -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
- }
@@ -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
- export const promptBoxRenderer: MessageRenderer<PromptBoxDetails> = (message, _options, theme) => {
154
- const content = typeof message.content === "string" ? message.content.replace(/(?![\n\t])[\p{Cc}\p{Cf}]/gu, "") : "";
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(content, details.submittedAt, width, theme, details);
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, foldedOutputStyleSection, OUTPUT_STYLE_MESSAGE_TYPE, OUTPUT_STYLE_SECTION, OUTPUT_STYLES, outputStyleSectionPatch } from "./output-style.ts";
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 { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
53
- import { isPlainObject, modelsResemble, sanitizedText, stripControlChars } from "./util.ts";
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
- * A decorated prompt is a custom message, so Pi does not run before_agent_start for it.
235
- * Put the missing prompt section on the request. A matching transcript section is left alone.
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
- #onContextWithSystem = (event: ContextWithSystemEvent): { messages: ContextWithSystemEvent["messages"] } | undefined => {
238
- const patch = outputStyleSectionPatch(foldedOutputStyleSection(event.messages), this.#settings.outputStyle);
239
- if (patch === undefined) return undefined;
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
- const stylePrompt = OUTPUT_STYLES[this.#settings.outputStyle].prompt;
268
- await this.#pi.sendMessage<PromptBoxDetails>(
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: stylePrompt === undefined },
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. Under a virtual selection
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
+ }