@underactive/pi-topping 0.9.1 → 0.10.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 -5
- package/package.json +3 -3
- package/src/autorouter-feed.ts +49 -0
- package/src/loader-message.ts +1 -1
- package/src/preview.ts +2 -0
- package/src/prompt-decorator.ts +0 -1
- package/src/served-model.ts +19 -0
- package/src/service-tier.ts +86 -0
- package/src/session.ts +120 -19
- package/src/settings.ts +3 -1
- package/src/util.ts +4 -0
package/README.md
CHANGED
|
@@ -6,11 +6,11 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
|
|
|
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. 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-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.
|
|
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. 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. 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.
|
|
14
14
|
|
|
15
15
|

|
|
16
16
|
|
|
@@ -86,6 +86,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
86
86
|
║ [■] Randomize “Worked” text ON ║
|
|
87
87
|
║ [■] Tokens spent ON ║
|
|
88
88
|
║ [■] Mid-turn inputs ON ║
|
|
89
|
+
║ [■] Service tier ON ║
|
|
89
90
|
║ [■] Response model ON ║
|
|
90
91
|
║ [■] Response model color ‹ muted › ║
|
|
91
92
|
║ [■] Response model dimmed OFF ║
|
|
@@ -109,7 +110,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
|
|
|
109
110
|
╟───────────────────────────────────────────────────────────────╢
|
|
110
111
|
║ ↑↓ move PgUp/PgDn page ←→ select ║
|
|
111
112
|
║ ␣ toggle ⏎ apply esc cancel ║
|
|
112
|
-
╚═══════════════════════════════════════════════════════[ 9/
|
|
113
|
+
╚═══════════════════════════════════════════════════════[ 9/58 ]╝
|
|
113
114
|
```
|
|
114
115
|
|
|
115
116
|
### Word packs
|
|
@@ -147,7 +148,7 @@ restarts the cycle.
|
|
|
147
148
|
|
|
148
149
|
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.
|
|
149
150
|
|
|
150
|
-
The response model is captured from assistant responses, displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
|
|
151
|
+
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.
|
|
151
152
|
|
|
152
153
|
## Requirements
|
|
153
154
|
|
|
@@ -163,13 +164,14 @@ npm test
|
|
|
163
164
|
npm run typecheck
|
|
164
165
|
```
|
|
165
166
|
|
|
166
|
-
Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior and its configurable upper bound, timer resets, settings persistence and schema migrations, word-pack parsing, loading, and default-text selection, response-model loader and footer behavior, the `menu.ts` component and its typed number entry, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
|
|
167
|
+
Tests cover the word/shimmer rendering, prompt box and completion marker rendering, activity meter behavior and its configurable upper bound, timer resets, settings persistence and schema migrations, word-pack parsing, loading, and default-text selection, response-model loader and footer behavior, served-model and service-tier extraction, the `menu.ts` component and its typed number entry, the settings menu wiring end to end, and the `/topping-setup` command's install and uninstall flows.
|
|
167
168
|
|
|
168
169
|
## Limitations
|
|
169
170
|
|
|
170
171
|
- 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.
|
|
171
172
|
- Elapsed time normally starts when you submit a prompt; for auto-continuations without that event, it starts slightly later instead.
|
|
172
173
|
- If another extension also customizes the working message, whichever one runs last wins.
|
|
174
|
+
- 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.
|
|
173
175
|
|
|
174
176
|
## Author
|
|
175
177
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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, 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": "1.0.
|
|
34
|
-
"@earendil-works/pi-tui": "1.0.
|
|
33
|
+
"@earendil-works/pi-coding-agent": "1.0.3",
|
|
34
|
+
"@earendil-works/pi-tui": "1.0.3",
|
|
35
35
|
"@types/node": "^24.0.0",
|
|
36
36
|
"typescript": "^5.9.3"
|
|
37
37
|
},
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { isPlainObject, sanitizedText } from "./util.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Event-bus channel on which pi-topping-autorouter announces the physical model it routed
|
|
5
|
+
* a request to, before Pi sends that request (so before `message_start`). Payload:
|
|
6
|
+
*
|
|
7
|
+
* ```json
|
|
8
|
+
* {
|
|
9
|
+
* "version": 1,
|
|
10
|
+
* "provider": "openai-codex",
|
|
11
|
+
* "modelId": "gpt-5.6-sol",
|
|
12
|
+
* "model": "openai-codex/gpt-5.6-sol",
|
|
13
|
+
* "name": "GPT-5.6 Sol",
|
|
14
|
+
* "thinkingLevel": "high",
|
|
15
|
+
* "reason": "user",
|
|
16
|
+
* "cause": "jev_classifier",
|
|
17
|
+
* "tier": "COMPLEX",
|
|
18
|
+
* "ts": 1790000000000
|
|
19
|
+
* }
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `reason` is Pi's route reason: `user`, `continuation`, `retry`, or `direct`. `direct` routes
|
|
23
|
+
* serve compaction summaries and other extensions' side requests, not the chat response.
|
|
24
|
+
* `tier` may be absent. Shared with pi-topping-autorouter.
|
|
25
|
+
*/
|
|
26
|
+
export const ROUTED_MODEL_CHANNEL = "pi-topping-autorouter:routed-model";
|
|
27
|
+
|
|
28
|
+
const ROUTE_REASONS = ["user", "continuation", "retry", "direct"] as const;
|
|
29
|
+
export type RouteReason = (typeof ROUTE_REASONS)[number];
|
|
30
|
+
|
|
31
|
+
export interface RoutedModelAnnouncement {
|
|
32
|
+
provider: string;
|
|
33
|
+
modelId: string;
|
|
34
|
+
reason: RouteReason;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRouteReason(value: string): value is RouteReason {
|
|
38
|
+
return ROUTE_REASONS.some((reason) => reason === value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The sanitized provider, model id, and route reason of a ROUTED_MODEL_CHANNEL announcement, or undefined if malformed. */
|
|
42
|
+
export function routedModelAnnouncement(data: unknown): RoutedModelAnnouncement | undefined {
|
|
43
|
+
if (!isPlainObject(data) || data.version !== 1) return undefined;
|
|
44
|
+
const provider = sanitizedText(data.provider);
|
|
45
|
+
const modelId = sanitizedText(data.modelId);
|
|
46
|
+
const reason = sanitizedText(data.reason);
|
|
47
|
+
if (!provider || !modelId || !isRouteReason(reason)) return undefined;
|
|
48
|
+
return { provider, modelId, reason };
|
|
49
|
+
}
|
package/src/loader-message.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, fadeThemeColor
|
|
|
3
3
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
4
4
|
import type { DecoratorSettings } from "./settings.ts";
|
|
5
5
|
|
|
6
|
-
type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate"
|
|
6
|
+
type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate">;
|
|
7
7
|
type Decorations = Pick<DecoratorSettings["decorations"], "shimmer" | "shimmerInverted" | "shimmerDirection" | "shimmerSpeed" | "tokenActivityMonitor" | "meterColor" | "meterDimmed" | "tokenRateColor" | "tokenRateDimmed" | "responseModelColor" | "responseModelDimmed" | "spinnerColor">;
|
|
8
8
|
interface LoaderParts {
|
|
9
9
|
spinnerAtMs?: number;
|
package/src/preview.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRat
|
|
|
5
5
|
import { buildLoaderMessage } from "./loader-message.ts";
|
|
6
6
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
7
7
|
import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
|
|
8
|
+
import { formatServiceTierSummary } from "./service-tier.ts";
|
|
8
9
|
import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleMeterUpperBound, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
|
|
9
10
|
import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, wordPacksPath, type WordPack } from "./word-packs.ts";
|
|
10
11
|
// Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
|
|
@@ -143,6 +144,7 @@ export class PreviewRenderer {
|
|
|
143
144
|
const details: string[] = [];
|
|
144
145
|
if (values.doneMarkerTokens === true) details.push("↓ 949 tokens");
|
|
145
146
|
if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
|
|
147
|
+
if (values.doneMarkerServiceTier === true) details.push(formatServiceTierSummary([{ tier: "flex", calls: 3 }]));
|
|
146
148
|
let model = "";
|
|
147
149
|
if (values.doneMarkerModel === true) {
|
|
148
150
|
const color = isThinkingLevelColor(values.doneMarkerModelColor) ? values.doneMarkerModelColor : DEFAULT_SETTINGS.decorations.doneMarkerModelColor;
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -96,7 +96,6 @@ export function buildPromptBoxLines(
|
|
|
96
96
|
const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
|
|
97
97
|
const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
|
|
98
98
|
const iconColor = (text: string) => theme.fg("text", text);
|
|
99
|
-
const label = (text: string) => theme.fg("customMessageLabel", text);
|
|
100
99
|
const muted = (text: string) => theme.fg("dim", text);
|
|
101
100
|
const bodyText = (text: string) => theme.fg("customMessageText", text);
|
|
102
101
|
const time = options.showTimestamp === false || !submittedAt
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isPlainObject, sanitizedText } from "./util.ts";
|
|
2
|
+
|
|
3
|
+
export interface ServedModelReport {
|
|
4
|
+
model: string;
|
|
5
|
+
backend?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Extract the served model and optional gateway backend from a raw Responses stream event. */
|
|
9
|
+
export function servedModelOf(data: unknown): ServedModelReport | undefined {
|
|
10
|
+
try {
|
|
11
|
+
if (!isPlainObject(data) || !isPlainObject(data.response)) return undefined;
|
|
12
|
+
const model = sanitizedText(data.response.model) || undefined;
|
|
13
|
+
if (!model) return undefined;
|
|
14
|
+
const backend = sanitizedText(data.provider);
|
|
15
|
+
return { model, ...(backend ? { backend } : {}) };
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { isPlainObject, sanitizedText } from "./util.ts";
|
|
2
|
+
|
|
3
|
+
/** Provider calls that reported one service tier during a run. */
|
|
4
|
+
export interface ServiceTierCount {
|
|
5
|
+
tier: string;
|
|
6
|
+
calls: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Sanitize a provider-supplied tier into a short, safe display label. */
|
|
10
|
+
export function sanitizeServiceTier(raw: unknown): string | undefined {
|
|
11
|
+
let tier = sanitizedText(raw);
|
|
12
|
+
if (tier.length > 32) {
|
|
13
|
+
let end = 0;
|
|
14
|
+
let characters = 0;
|
|
15
|
+
for (const character of tier) {
|
|
16
|
+
if (characters === 32) break;
|
|
17
|
+
end += character.length;
|
|
18
|
+
characters++;
|
|
19
|
+
}
|
|
20
|
+
tier = tier.slice(0, end);
|
|
21
|
+
}
|
|
22
|
+
return tier || undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Return the first sanitized tier reported in a raw provider stream event, without throwing.
|
|
27
|
+
* OpenAI chat-completions uses `service_tier`; Responses uses `response.service_tier`;
|
|
28
|
+
* Anthropic reports `message.usage.service_tier` at `message_start` and
|
|
29
|
+
* `usage.service_tier` at `message_delta`. `serviceTier` covers camel-case adapters.
|
|
30
|
+
*/
|
|
31
|
+
export function serviceTierOf(data: unknown): string | undefined {
|
|
32
|
+
try {
|
|
33
|
+
if (!isPlainObject(data)) return undefined;
|
|
34
|
+
const direct = sanitizeServiceTier(data.service_tier) ?? sanitizeServiceTier(data.serviceTier);
|
|
35
|
+
if (direct) return direct;
|
|
36
|
+
if (isPlainObject(data.response)) {
|
|
37
|
+
const responseTier = sanitizeServiceTier(data.response.service_tier);
|
|
38
|
+
if (responseTier) return responseTier;
|
|
39
|
+
}
|
|
40
|
+
if (isPlainObject(data.usage)) {
|
|
41
|
+
const usageTier = sanitizeServiceTier(data.usage.service_tier);
|
|
42
|
+
if (usageTier) return usageTier;
|
|
43
|
+
}
|
|
44
|
+
if (isPlainObject(data.message) && isPlainObject(data.message.usage)) {
|
|
45
|
+
return sanitizeServiceTier(data.message.usage.service_tier);
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
} catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Increment a tier's provider-call count, appending new tiers in first-seen order. */
|
|
54
|
+
export function recordServiceTierCall(counts: ServiceTierCount[], tier: string): void {
|
|
55
|
+
const existing = counts.find((entry) => entry.tier === tier);
|
|
56
|
+
if (existing) existing.calls++;
|
|
57
|
+
else counts.push({ tier, calls: 1 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Validate persisted tier counts, dropping malformed entries and merging sanitized duplicates. */
|
|
61
|
+
export function serviceTierCountsOf(raw: unknown): ServiceTierCount[] {
|
|
62
|
+
if (!Array.isArray(raw)) return [];
|
|
63
|
+
const counts: ServiceTierCount[] = [];
|
|
64
|
+
for (const item of raw) {
|
|
65
|
+
if (!isPlainObject(item)) continue;
|
|
66
|
+
const tier = sanitizeServiceTier(item.tier);
|
|
67
|
+
const calls = item.calls;
|
|
68
|
+
if (!tier || typeof calls !== "number" || !Number.isSafeInteger(calls) || calls <= 0) continue;
|
|
69
|
+
const existing = counts.find((entry) => entry.tier === tier);
|
|
70
|
+
if (existing) {
|
|
71
|
+
const sum = existing.calls + calls;
|
|
72
|
+
if (Number.isSafeInteger(sum)) existing.calls = sum;
|
|
73
|
+
} else {
|
|
74
|
+
counts.push({ tier, calls });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return counts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Format the ordered summary of provider calls that reported a tier. */
|
|
81
|
+
export function formatServiceTierSummary(counts: readonly ServiceTierCount[]): string {
|
|
82
|
+
if (counts.length === 0) return "";
|
|
83
|
+
const totalCalls = counts.reduce((total, entry) => total + entry.calls, 0);
|
|
84
|
+
if (totalCalls === 1) return `${counts[0]!.tier} tier`;
|
|
85
|
+
return counts.map(({ tier, calls }) => `${tier} tier ×${calls}`).join(", ");
|
|
86
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
16
16
|
import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
|
|
17
|
+
import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./autorouter-feed.ts";
|
|
17
18
|
import {
|
|
18
19
|
dimAttribute,
|
|
19
20
|
getThinkingLevelColorizer,
|
|
@@ -46,7 +47,9 @@ import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.
|
|
|
46
47
|
import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
|
|
47
48
|
import { PreviewRenderer } from "./preview.ts";
|
|
48
49
|
import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
|
|
49
|
-
import { isPlainObject, modelsResemble, stripControlChars } from "./util.ts";
|
|
50
|
+
import { isPlainObject, modelsResemble, sanitizedText, stripControlChars } from "./util.ts";
|
|
51
|
+
import { formatServiceTierSummary, recordServiceTierCall, serviceTierCountsOf, serviceTierOf, type ServiceTierCount } from "./service-tier.ts";
|
|
52
|
+
import { servedModelOf } from "./served-model.ts";
|
|
50
53
|
import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
|
|
51
54
|
|
|
52
55
|
type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
|
|
@@ -55,10 +58,13 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
|
|
|
55
58
|
type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
|
|
56
59
|
type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
|
|
57
60
|
type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
|
|
61
|
+
type ProviderStreamEvent = Extract<ExtensionEvent, { type: "provider_stream_event" }>;
|
|
58
62
|
type UIPromptKind = UIPromptStartEvent["kind"];
|
|
59
63
|
|
|
60
64
|
const DONE_ENTRY_TYPE = "pi-topping-done";
|
|
61
65
|
const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
|
|
66
|
+
/** Pi's `VIRTUAL_MODEL_API` for extension-registered virtual models; Pi does not export it from its public index. */
|
|
67
|
+
const VIRTUAL_MODEL_API = "pi-virtual";
|
|
62
68
|
const WAITING_LABELS: Record<UIPromptKind, string> = {
|
|
63
69
|
select: "Waiting for selection",
|
|
64
70
|
confirm: "Waiting for confirmation",
|
|
@@ -77,10 +83,26 @@ interface DoneEntryData {
|
|
|
77
83
|
elapsedMs: number;
|
|
78
84
|
tokens?: number;
|
|
79
85
|
midTurnInputs?: number;
|
|
86
|
+
serviceTiers?: ServiceTierCount[];
|
|
80
87
|
thinkingLevel?: ThinkingLevel;
|
|
81
88
|
model?: string;
|
|
82
89
|
}
|
|
83
90
|
|
|
91
|
+
/** A response model as one source reports it, for `updateResponseModel`. */
|
|
92
|
+
interface ResponseModelReport {
|
|
93
|
+
raw: unknown;
|
|
94
|
+
/**
|
|
95
|
+
* True for a bare routed model id (a router announcement or, under a virtual selection, the
|
|
96
|
+
* message's `model`) rather than a provider-reported value. A weak value never displaces a
|
|
97
|
+
* decorated form of itself the provider already reported, such as a dated name.
|
|
98
|
+
*/
|
|
99
|
+
weak: boolean;
|
|
100
|
+
/** The message's own provider when `raw` is its `model`, to recognize a failed route. */
|
|
101
|
+
provider?: string;
|
|
102
|
+
backend?: string;
|
|
103
|
+
routedId?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
84
106
|
interface SessionState {
|
|
85
107
|
startTime: number;
|
|
86
108
|
workingText: WorkingTextSelection;
|
|
@@ -92,6 +114,12 @@ interface SessionState {
|
|
|
92
114
|
lastTokenRateSampledAt: number;
|
|
93
115
|
lastTokenRateTotal: number;
|
|
94
116
|
responseModel: string;
|
|
117
|
+
served: { model: string; backend: string; routedId: string } | null;
|
|
118
|
+
/** The backend companion to the last raw response-model report. */
|
|
119
|
+
lastResponseModelBackend: string | undefined | typeof NOT_SENT;
|
|
120
|
+
/** The tier reported by the in-flight provider call, added to counts at its assistant message_end. */
|
|
121
|
+
pendingServiceTier: string;
|
|
122
|
+
serviceTiers: ServiceTierCount[];
|
|
95
123
|
/** Last raw responseModel value seen, or the NOT_SENT sentinel before the first call. */
|
|
96
124
|
lastResponseModelRaw: unknown;
|
|
97
125
|
responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
|
|
@@ -134,6 +162,10 @@ function makeFreshState(): SessionState {
|
|
|
134
162
|
lastTokenRateSampledAt: 0,
|
|
135
163
|
lastTokenRateTotal: 0,
|
|
136
164
|
responseModel: "",
|
|
165
|
+
served: null,
|
|
166
|
+
lastResponseModelBackend: NOT_SENT,
|
|
167
|
+
pendingServiceTier: "",
|
|
168
|
+
serviceTiers: [],
|
|
137
169
|
lastResponseModelRaw: NOT_SENT,
|
|
138
170
|
responseModelHoldTimer: null,
|
|
139
171
|
responseModelFadeTimer: null,
|
|
@@ -155,10 +187,6 @@ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
|
|
|
155
187
|
return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
|
|
156
188
|
}
|
|
157
189
|
|
|
158
|
-
function responseModelOf(message: unknown): unknown {
|
|
159
|
-
return isPlainObject(message) ? message.responseModel : undefined;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
190
|
/** Owns mutable extension state and Pi lifecycle registrations. */
|
|
163
191
|
export class SessionManager {
|
|
164
192
|
#counter = new StreamingWordCounter();
|
|
@@ -246,7 +274,7 @@ export class SessionManager {
|
|
|
246
274
|
if (this.usable(ctx) && event.message.role === "assistant") {
|
|
247
275
|
this.#state.liveTokens = 0;
|
|
248
276
|
this.#counter.reset();
|
|
249
|
-
this.updateResponseModel(responseModelOf(event.message));
|
|
277
|
+
this.updateResponseModel(this.responseModelOf(event.message));
|
|
250
278
|
}
|
|
251
279
|
};
|
|
252
280
|
|
|
@@ -260,14 +288,15 @@ export class SessionManager {
|
|
|
260
288
|
) {
|
|
261
289
|
this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
|
|
262
290
|
}
|
|
263
|
-
const rawResponseModel = (assistantEvent as { partial?:
|
|
264
|
-
?? responseModelOf(event.message);
|
|
291
|
+
const rawResponseModel = this.responseModelOf((assistantEvent as { partial?: unknown } | undefined)?.partial)
|
|
292
|
+
?? this.responseModelOf(event.message);
|
|
265
293
|
this.updateResponseModel(rawResponseModel);
|
|
266
294
|
};
|
|
267
295
|
|
|
268
296
|
#onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
|
|
269
297
|
this.#currentCtx = ctx;
|
|
270
298
|
if (!this.usable(ctx) || event.message.role !== "assistant") return;
|
|
299
|
+
this.commitServiceTierCall();
|
|
271
300
|
|
|
272
301
|
const rawTokens = event.message.usage?.output;
|
|
273
302
|
const exactTokens =
|
|
@@ -282,9 +311,25 @@ export class SessionManager {
|
|
|
282
311
|
}
|
|
283
312
|
this.#state.liveTokens = 0;
|
|
284
313
|
this.#counter.reset();
|
|
285
|
-
this.updateResponseModel(responseModelOf(event.message));
|
|
314
|
+
this.updateResponseModel(this.responseModelOf(event.message));
|
|
286
315
|
};
|
|
287
316
|
|
|
317
|
+
#onProviderStreamEvent = (event: ProviderStreamEvent, ctx: ExtensionContext): void => {
|
|
318
|
+
if (!this.usable(ctx) || !this.#state.busy) return;
|
|
319
|
+
const tier = serviceTierOf(event.data);
|
|
320
|
+
if (tier) this.#state.pendingServiceTier = tier;
|
|
321
|
+
const served = servedModelOf(event.data);
|
|
322
|
+
if (served) {
|
|
323
|
+
this.updateResponseModel({ raw: served.model, weak: false, provider: event.provider, backend: served.backend });
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
private commitServiceTierCall(): void {
|
|
328
|
+
if (!this.#state.pendingServiceTier) return;
|
|
329
|
+
recordServiceTierCall(this.#state.serviceTiers, this.#state.pendingServiceTier);
|
|
330
|
+
this.#state.pendingServiceTier = "";
|
|
331
|
+
}
|
|
332
|
+
|
|
288
333
|
#onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
|
|
289
334
|
this.#currentCtx = ctx;
|
|
290
335
|
if (this.usable(ctx)) {
|
|
@@ -314,6 +359,7 @@ export class SessionManager {
|
|
|
314
359
|
#onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
|
|
315
360
|
this.#currentCtx = ctx;
|
|
316
361
|
if (!this.usable(ctx)) return;
|
|
362
|
+
this.commitServiceTierCall();
|
|
317
363
|
|
|
318
364
|
const hadPrompt = !!this.#state.startTime;
|
|
319
365
|
const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
|
|
@@ -338,6 +384,7 @@ export class SessionManager {
|
|
|
338
384
|
elapsedMs,
|
|
339
385
|
tokens: this.#state.confirmTokens,
|
|
340
386
|
midTurnInputs: this.#state.midTurnInputs,
|
|
387
|
+
...(this.#state.serviceTiers.length ? { serviceTiers: this.#state.serviceTiers.map((count) => ({ ...count })) } : {}),
|
|
341
388
|
thinkingLevel: ctx.thinkingLevel,
|
|
342
389
|
model: this.#state.responseModel || undefined,
|
|
343
390
|
});
|
|
@@ -362,9 +409,13 @@ export class SessionManager {
|
|
|
362
409
|
if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && Number.isInteger(entry.data.midTurnInputs) && entry.data.midTurnInputs > 0) {
|
|
363
410
|
details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
|
|
364
411
|
}
|
|
412
|
+
if (features.doneMarkerServiceTier) {
|
|
413
|
+
const summary = formatServiceTierSummary(serviceTierCountsOf(entry.data.serviceTiers));
|
|
414
|
+
if (summary) details.push(summary);
|
|
415
|
+
}
|
|
365
416
|
const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
|
|
366
417
|
const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
|
|
367
|
-
const rawModel =
|
|
418
|
+
const rawModel = sanitizedText(entry.data.model);
|
|
368
419
|
const modelColored = features.doneMarkerModel && rawModel
|
|
369
420
|
? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
|
|
370
421
|
: "";
|
|
@@ -399,6 +450,7 @@ export class SessionManager {
|
|
|
399
450
|
this.#pi.on("message_start", this.#onMessageStart);
|
|
400
451
|
this.#pi.on("message_update", this.#onMessageUpdate);
|
|
401
452
|
this.#pi.on("message_end", this.#onMessageEnd);
|
|
453
|
+
this.#pi.on("provider_stream_event", this.#onProviderStreamEvent);
|
|
402
454
|
this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
|
|
403
455
|
this.#pi.on("agent_settled", this.#onAgentSettled);
|
|
404
456
|
this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
|
|
@@ -407,6 +459,13 @@ export class SessionManager {
|
|
|
407
459
|
this.#pi.events.on(STATUSLINE_EMBED_CHANNEL, (data) => {
|
|
408
460
|
this.#loaderInStatusline = announcesEmbedded(data);
|
|
409
461
|
});
|
|
462
|
+
this.#pi.events.on(ROUTED_MODEL_CHANNEL, (data) => {
|
|
463
|
+
// Direct routes serve compaction and other extensions' side requests; a route while idle
|
|
464
|
+
// is such a background request too, and must not leave a stale model for the next turn.
|
|
465
|
+
const announcement = routedModelAnnouncement(data);
|
|
466
|
+
if (!announcement || announcement.reason === "direct" || !this.#state.busy) return;
|
|
467
|
+
this.updateResponseModel({ raw: announcement.modelId, weak: true, provider: announcement.provider, routedId: announcement.modelId });
|
|
468
|
+
});
|
|
410
469
|
this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
|
|
411
470
|
this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
|
|
412
471
|
this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
|
|
@@ -475,25 +534,63 @@ export class SessionManager {
|
|
|
475
534
|
}
|
|
476
535
|
}
|
|
477
536
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
537
|
+
/**
|
|
538
|
+
* The response model a message reports. Under a virtual selection Pi stamps the routed
|
|
539
|
+
* physical model on the message's `model` instead, so fall back to that as a weak value;
|
|
540
|
+
* a failed route leaves the virtual model itself there, which `restatesSelection` hides.
|
|
541
|
+
*/
|
|
542
|
+
private responseModelOf(message: unknown): ResponseModelReport | undefined {
|
|
543
|
+
if (!isPlainObject(message)) return undefined;
|
|
544
|
+
if (message.responseModel !== undefined) {
|
|
545
|
+
return { raw: message.responseModel, weak: false, routedId: sanitizedText(message.model) || undefined };
|
|
546
|
+
}
|
|
547
|
+
if (this.#currentCtx?.model?.api !== VIRTUAL_MODEL_API) return undefined;
|
|
548
|
+
return { raw: message.model, weak: true, provider: typeof message.provider === "string" ? message.provider : undefined };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Whether a response model merely restates the selected model. Under a virtual selection
|
|
553
|
+
* the routed model is always another model, so only a failed route, which leaves the
|
|
554
|
+
* virtual model itself on the message, is hidden; a resemblance check would also hide a
|
|
555
|
+
* routed model whose id contains the virtual id, such as openrouter's `auto`.
|
|
556
|
+
*/
|
|
557
|
+
private restatesSelection(report: ResponseModelReport, responseModel: string): boolean {
|
|
558
|
+
const selected = this.#currentCtx?.model;
|
|
559
|
+
if (!selected) return false;
|
|
560
|
+
if (selected.api === VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
|
|
561
|
+
return modelsResemble(selected.id, responseModel);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private updateResponseModel(report: ResponseModelReport | undefined): void {
|
|
565
|
+
const backendReport = report?.backend;
|
|
566
|
+
if (report?.raw === this.#state.lastResponseModelRaw && backendReport === this.#state.lastResponseModelBackend) return;
|
|
567
|
+
this.#state.lastResponseModelRaw = report?.raw;
|
|
568
|
+
this.#state.lastResponseModelBackend = backendReport;
|
|
569
|
+
if (!report) return;
|
|
570
|
+
const model = sanitizedText(report.raw);
|
|
571
|
+
if (!model) return;
|
|
572
|
+
const backend = report.backend ?? (model === this.#state.served?.model ? this.#state.served.backend : "");
|
|
573
|
+
const showBackend = !!backend && backend !== report.provider;
|
|
574
|
+
const restates = this.restatesSelection(report, model);
|
|
575
|
+
const display = restates ? (showBackend ? `via ${backend}` : "") : (showBackend ? `${model} via ${backend}` : model);
|
|
576
|
+
if (!display) {
|
|
484
577
|
if (this.#state.responseModel) {
|
|
485
578
|
this.#state.responseModel = "";
|
|
486
579
|
this.stopResponseModelSlide();
|
|
487
580
|
this.tick();
|
|
488
581
|
}
|
|
582
|
+
this.#state.served = null;
|
|
489
583
|
return;
|
|
490
584
|
}
|
|
491
|
-
if (
|
|
585
|
+
if (report.weak && model === this.#state.served?.routedId) return;
|
|
586
|
+
const changed = display !== this.#state.responseModel;
|
|
587
|
+
if (changed) {
|
|
492
588
|
// A model replacing one already shown swaps in place; only an appearing one slides out.
|
|
493
589
|
if (!this.#state.responseModel) this.startResponseModelSlide();
|
|
494
|
-
this.#state.responseModel =
|
|
495
|
-
this.tick();
|
|
590
|
+
this.#state.responseModel = display;
|
|
496
591
|
}
|
|
592
|
+
this.#state.served = { model, backend, routedId: report.routedId ?? model };
|
|
593
|
+
if (changed) this.tick();
|
|
497
594
|
}
|
|
498
595
|
|
|
499
596
|
/**
|
|
@@ -601,7 +698,11 @@ export class SessionManager {
|
|
|
601
698
|
state.confirmTokens = 0;
|
|
602
699
|
state.liveTokens = 0;
|
|
603
700
|
state.responseModel = "";
|
|
701
|
+
state.served = null;
|
|
702
|
+
state.pendingServiceTier = "";
|
|
703
|
+
state.serviceTiers = [];
|
|
604
704
|
state.lastResponseModelRaw = NOT_SENT;
|
|
705
|
+
state.lastResponseModelBackend = NOT_SENT;
|
|
605
706
|
state.activityMeter.reset();
|
|
606
707
|
resetTokenRateState(state);
|
|
607
708
|
state.midTurnInputs = 0;
|
package/src/settings.ts
CHANGED
|
@@ -119,6 +119,7 @@ export interface DecoratorSettings {
|
|
|
119
119
|
randomizeDoneMarker: boolean;
|
|
120
120
|
doneMarkerTokens: boolean;
|
|
121
121
|
doneMarkerInputs: boolean;
|
|
122
|
+
doneMarkerServiceTier: boolean;
|
|
122
123
|
doneMarkerModel: boolean;
|
|
123
124
|
includeDefaultWorkingText: boolean;
|
|
124
125
|
};
|
|
@@ -128,7 +129,7 @@ export interface DecoratorSettings {
|
|
|
128
129
|
|
|
129
130
|
export const DEFAULT_SETTINGS: DecoratorSettings = {
|
|
130
131
|
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, meterUpperBoundTps: DEFAULT_METER_UPPER_BOUND_TPS, tokenRateColor: "warning", tokenRateDimmed: false, responseModelColor: "accent", responseModelDimmed: false, promptIcon: true, promptTimestamp: true, promptProvider: true, promptModel: true, useNerdFont: true },
|
|
131
|
-
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerModel: true, includeDefaultWorkingText: true },
|
|
132
|
+
features: { substituteDefaultMessage: true, elapsedTime: true, outputTokens: true, tokenRate: true, responseModel: true, responseModelFooter: false, doneMarker: true, doneMarkerIcon: true, randomizeDoneMarker: true, doneMarkerTokens: true, doneMarkerInputs: true, doneMarkerServiceTier: true, doneMarkerModel: true, includeDefaultWorkingText: true },
|
|
132
133
|
loaderOrder: [...DEFAULT_LOADER_ORDER],
|
|
133
134
|
wordPacks: {},
|
|
134
135
|
};
|
|
@@ -231,6 +232,7 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
|
|
|
231
232
|
{ id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
|
|
232
233
|
{ id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
|
|
233
234
|
{ id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
|
|
235
|
+
{ id: "doneMarkerServiceTier", label: "Service tier", section: "Completion Marker", group: "features", key: "doneMarkerServiceTier" },
|
|
234
236
|
{ id: "doneMarkerModel", label: "Response model", section: "Completion Marker", group: "features", key: "doneMarkerModel" },
|
|
235
237
|
{ id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
|
|
236
238
|
{ id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" },
|
package/src/util.ts
CHANGED
|
@@ -8,6 +8,10 @@ export function stripControlChars(text: string): string {
|
|
|
8
8
|
return text.replace(/[\p{Cc}\p{Cf}]/gu, "");
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export function sanitizedText(value: unknown): string {
|
|
12
|
+
return typeof value === "string" ? stripControlChars(value).trim() : "";
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
/** True when a reported response model appears to be a decorated form of the selected model. */
|
|
12
16
|
export function modelsResemble(selectedModel?: string, responseModel?: string): boolean {
|
|
13
17
|
if (!selectedModel || !responseModel) return false;
|