@underactive/pi-topping 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
6
6
 
7
7
  ![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-autorouter's `litellm-auto/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 (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.
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
 
@@ -167,7 +167,7 @@ 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-autorouter's `litellm-auto/auto`, it is the routed physical model: taken from the router's `pi-topping-autorouter: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 (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.
171
171
 
172
172
  ## Requirements
173
173
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "A Pi extension that adds quality of life improvements to Pi's UX: a high-vis user prompt, a shimmering working loader with token activity meter and elapsed timer, an end-of-turn completion marker, and selectable output styles",
5
5
  "keywords": ["pi-package", "pi-extension"],
6
6
  "license": "MIT",
@@ -170,3 +170,31 @@ 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,7 +1,7 @@
1
1
  import { isPlainObject, sanitizedText } from "./util.ts";
2
2
 
3
3
  /**
4
- * Event-bus channel on which pi-topping-autorouter announces the physical model it routed
4
+ * Event-bus channel on which pi-topping-routesense announces the physical model it routed
5
5
  * a request to, before Pi sends that request (so before `message_start`). Payload:
6
6
  *
7
7
  * ```json
@@ -21,9 +21,9 @@ import { isPlainObject, sanitizedText } from "./util.ts";
21
21
  *
22
22
  * `reason` is Pi's route reason: `user`, `continuation`, `retry`, or `direct`. `direct` routes
23
23
  * serve compaction summaries and other extensions' side requests, not the chat response.
24
- * `tier` may be absent. Shared with pi-topping-autorouter.
24
+ * `tier` may be absent. Shared with pi-topping-routesense.
25
25
  */
26
- export const ROUTED_MODEL_CHANNEL = "pi-topping-autorouter:routed-model";
26
+ export const ROUTED_MODEL_CHANNEL = "pi-topping-routesense:routed-model";
27
27
 
28
28
  const ROUTE_REASONS = ["user", "continuation", "retry", "direct"] as const;
29
29
  export type RouteReason = (typeof ROUTE_REASONS)[number];
package/src/session.ts CHANGED
@@ -16,7 +16,7 @@ import type {
16
16
  } from "@earendil-works/pi-coding-agent";
17
17
  import { Text, type Component } from "@earendil-works/pi-tui";
18
18
  import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
19
- import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./autorouter-feed.ts";
19
+ import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./routesense-feed.ts";
20
20
  import {
21
21
  dimAttribute,
22
22
  getThinkingLevelColorizer,
@@ -42,7 +42,7 @@ import {
42
42
  } from "./format.ts";
43
43
  import { buildLoaderMessage } from "./loader-message.ts";
44
44
  import { showMenu } from "./menu.ts";
45
- import { applyOutputStyle, OUTPUT_STYLES } from "./output-style.ts";
45
+ import { applyOutputStyle, foldedOutputStyleSection, OUTPUT_STYLE_MESSAGE_TYPE, OUTPUT_STYLE_SECTION, OUTPUT_STYLES, outputStyleSectionPatch } from "./output-style.ts";
46
46
  import { getResponseModelColorizer } from "./nvidia-green.ts";
47
47
  import { registerSetupCommand } from "./setup-command.ts";
48
48
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
@@ -61,6 +61,7 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
61
61
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
62
62
  type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
63
63
  type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
64
+ type ContextWithSystemEvent = Extract<ExtensionEvent, { type: "context_with_system" }>;
64
65
  type ProviderStreamEvent = Extract<ExtensionEvent, { type: "provider_stream_event" }>;
65
66
  type UIPromptKind = UIPromptStartEvent["kind"];
66
67
 
@@ -229,18 +230,41 @@ export class SessionManager {
229
230
  #onBeforeAgentStart = (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined =>
230
231
  applyOutputStyle(event.systemPromptOptions?.sections, this.#settings.outputStyle);
231
232
 
233
+ /**
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.
236
+ */
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 };
257
+ };
258
+
232
259
  #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
233
260
  this.#currentCtx = ctx;
234
261
  if (!this.usable(ctx)) return;
235
262
 
236
263
  if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
237
264
  else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
238
- if (
239
- this.#settings.decorations.decorateUserPrompt &&
240
- OUTPUT_STYLES[this.#settings.outputStyle].prompt === undefined &&
241
- this.shouldDecorate(event)
242
- ) {
265
+ if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
243
266
  try {
267
+ const stylePrompt = OUTPUT_STYLES[this.#settings.outputStyle].prompt;
244
268
  await this.#pi.sendMessage<PromptBoxDetails>(
245
269
  {
246
270
  customType: PROMPT_BOX_TYPE,
@@ -260,8 +284,18 @@ export class SessionManager {
260
284
  thinkingLevel: ctx.thinkingLevel,
261
285
  },
262
286
  },
263
- { triggerTurn: true },
287
+ { triggerTurn: stylePrompt === undefined },
264
288
  );
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
+ }
265
299
  return { action: "handled" };
266
300
  } catch {
267
301
  ctx.ui.notify("Failed to submit prompt", "error");
@@ -461,6 +495,7 @@ export class SessionManager {
461
495
  install(): void {
462
496
  this.#pi.on("session_start", this.#onSessionStart);
463
497
  this.#pi.on("before_agent_start", this.#onBeforeAgentStart);
498
+ this.#pi.on("context_with_system", this.#onContextWithSystem);
464
499
  this.#pi.on("input", this.#onInput);
465
500
  this.#pi.on("agent_start", this.#onAgentStart);
466
501
  this.#pi.on("message_start", this.#onMessageStart);