@underactive/pi-topping 0.9.1 → 0.11.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
@@ -6,14 +6,16 @@ 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. 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
  ![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. 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. 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.
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
 
17
+ **Output Styles** — choose prompt-instruction styles such as Explanatory or Simplified Technical English to guide how the model responds. Styles are instructions, not output formatters.
18
+
17
19
  ## Install
18
20
 
19
21
  ```bash
@@ -86,6 +88,7 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
86
88
  ║ [■] Randomize “Worked” text ON ║
87
89
  ║ [■] Tokens spent ON ║
88
90
  ║ [■] Mid-turn inputs ON ║
91
+ ║ [■] Service tier ON ║
89
92
  ║ [■] Response model ON ║
90
93
  ║ [■] Response model color ‹ muted › ║
91
94
  ║ [■] Response model dimmed OFF ║
@@ -103,15 +106,30 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
103
106
  ║ ║
104
107
  ║ [■] Include default/random “Working” text ON ║
105
108
  ║ ║
109
+ ╟─ Output Style ────────────────────────────────────────────────╢
110
+ ║ [■] Style ‹ Default › ║
111
+ ║ ║
106
112
  ╟─ Options ─────────────────────────────────────────────────────╢
107
113
  ║ [■] Use NerdFont icons ON ║
108
114
  ║ [ ] Response model footer OFF ║
109
115
  ╟───────────────────────────────────────────────────────────────╢
110
116
  ║ ↑↓ move PgUp/PgDn page ←→ select ║
111
117
  ║ ␣ toggle ⏎ apply esc cancel ║
112
- ╚═══════════════════════════════════════════════════════[ 9/57 ]╝
118
+ ╚═══════════════════════════════════════════════════════[ 9/59 ]╝
113
119
  ```
114
120
 
121
+ ### Output styles
122
+
123
+ Choose an output style with `←`/`→` in the Output Style section of `/topping-settings`. The selected style changes the model's prompt instructions from your next message.
124
+ - **Explanatory** requests brief Insight blocks before and after coding. This is a prompt instruction, not a formatter, so a turn may have no Insight.
125
+ - **Simplified Technical English** instructs the model to write all English prose (not code or quoted text) using ASD-STE100 Simplified Technical English (controlled language rules, active voice, simple tenses, short sentences, and small noun clusters).
126
+
127
+ While a non-default style is active, each prompt also carries a one-line hidden reminder of the style; the model receives it, but the transcript does not show it.
128
+
129
+ Output styles also apply in print and RPC modes. Set `"outputStyle": "explanatory"` (or `"simplified"`) in `~/.pi/agent/pi-topping/settings.json`; if you edit the file while an interactive session is running, use `/reload` to load the change. Switching styles changes one system-prompt section. Pi appends that change to the conversation, so providers that support mid-conversation prompt updates keep their cached prefix; other providers may re-read the full prompt once.
130
+
131
+ The Explanatory prompt is copied word for word from Claude Code's output styles, with two Pi clarifications: a lead-in that frames the instructions for Pi, and an exception allowing Insight blocks despite Pi's conciseness rule. The Simplified Technical English prompt adapts the ASD-STE100 writing rules but does not include the STE dictionary of approved words. A more specific instruction from you, your project instructions, a skill, or the file being edited takes precedence over it. When migrating from `pi-topping-insights`, its saved style is imported until you next save `/topping-settings`. Remove the old extension to avoid duplicate hidden reminders. `/insights` is gone; use `/topping-settings`.
132
+
115
133
  ### Word packs
116
134
 
117
135
  Base activity words are included by default. All word packs, including the shipped **Doctor Who**, **Firefly**, **Hitchhiker's Guide**, **The Lord of the Rings**, **The Matrix**, **Portal**, **SimCity**, **Star Trek**, and **Star Wars** packs and custom packs, are disabled by default. Enable packs in `/topping-settings` to add their entries to the same uniformly selected pool. With at least one pack enabled, turn off **Include default/random “Working” text** to draw only from enabled packs; with none enabled the option is locked on. When Randomize “Worked” text is on, the completion marker uses the selected entry’s matching past tense; otherwise it uses `Worked`.
@@ -131,6 +149,8 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
131
149
 
132
150
  Pack IDs must start with a lowercase letter, contain only lowercase letters, digits, and `-`, and be at most 64 characters. Each pack needs a non-empty `name` and at least one word with non-empty `present_tense` and `past_tense` strings. An optional `description` appears in the settings preview when the pack row is selected, and an optional `attribution` records the source. Invalid entries are ignored. Packs reload when a session starts and whenever `/topping-settings` opens, not while a turn is running. Preferences for missing packs are retained so they apply when the pack returns.
133
151
 
152
+ ### Colors and loader details
153
+
134
154
  Every color setting offers `thinking-level` (shown as `thinkingLevel` in `/topping-settings`); it is the default for the animated spinner, the prompt border, and the completion marker border. The spinner, token activity monitor, token rate, and response model follow Pi's current thinking level. The prompt border uses the level captured when the prompt was submitted, and the completion marker uses the level captured when the turn settled, so earlier entries keep their color after you change level. When hand-editing `settings.json`, write `thinking-level`; the legacy `default` spelling is only recognised for `spinnerColor` and `doneMarkerBorderColor`.
135
155
 
136
156
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
@@ -147,11 +167,13 @@ restarts the cycle.
147
167
 
148
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.
149
169
 
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.
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.
151
171
 
152
172
  ## Requirements
153
173
 
154
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.
155
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.
156
178
  - Node.js ≥22.19.0
157
179
 
@@ -163,13 +185,15 @@ npm test
163
185
  npm run typecheck
164
186
  ```
165
187
 
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.
188
+ 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, output-style selection and prompt integration, 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
189
 
168
190
  ## Limitations
169
191
 
170
192
  - 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
193
  - Elapsed time normally starts when you submit a prompt; for auto-continuations without that event, it starts slightly later instead.
172
194
  - 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).
173
197
 
174
198
  ## Author
175
199
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.9.1",
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",
3
+ "version": "0.11.0",
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",
7
7
  "author": "Eric Sison",
@@ -30,8 +30,8 @@
30
30
  "@earendil-works/pi-tui": "*"
31
31
  },
32
32
  "devDependencies": {
33
- "@earendil-works/pi-coding-agent": "1.0.0",
34
- "@earendil-works/pi-tui": "1.0.0",
33
+ "@earendil-works/pi-coding-agent": "1.1.0",
34
+ "@earendil-works/pi-tui": "1.1.0",
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
+ }
@@ -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" | "responseModel">;
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;
@@ -0,0 +1,172 @@
1
+ import type { BeforeAgentStartEventResult } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const OUTPUT_STYLE_IDS = ["default", "explanatory", "simplified"] as const;
4
+ export type OutputStyleId = (typeof OUTPUT_STYLE_IDS)[number];
5
+
6
+ export interface OutputStyle {
7
+ label: string;
8
+ /** Settings-preview line; keep within 60 columns so the menu doesn't truncate it. */
9
+ description: string;
10
+ /** System-prompt section and hidden reminder; omitted for Default, which leaves Pi's prompt unchanged. */
11
+ prompt?: { section: string; reminder: string };
12
+ }
13
+
14
+ // Matches figures.star from Claude Code: a fallback glyph on Windows, and ★ elsewhere.
15
+ export const INSIGHT_STAR = process.platform === "win32" ? "✶" : "★";
16
+
17
+ // Ported verbatim from claude-code/src/constants/outputStyles.ts:30-39.
18
+ export const EXPLANATORY_FEATURE_PROMPT = `
19
+ ## Insights
20
+ In order to encourage learning, before and after writing code, always provide brief educational explanations about implementation choices using (with backticks):
21
+ "\`${INSIGHT_STAR} Insight ─────────────────────────────────────\`
22
+ [2-3 key educational points]
23
+ \`─────────────────────────────────────────────────\`"
24
+
25
+ These insights should be included in the conversation, not in the codebase. You should generally focus on interesting insights that are specific to the codebase or the code you just wrote, rather than general programming concepts.`;
26
+
27
+ export const OUTPUT_STYLE_SECTION = "output_style";
28
+ export const OUTPUT_STYLE_MESSAGE_TYPE = "pi-topping-output-style";
29
+
30
+ export const EXPLANATORY_SECTION = `# Output Style: Explanatory
31
+ Respond to the user according to this Output Style, which describes how you should respond to user queries.
32
+ You are an interactive CLI tool that helps users with software engineering tasks. In addition to software engineering tasks, you should provide educational insights about the codebase along the way.
33
+
34
+ You should be clear and educational, providing helpful explanations while remaining focused on the task. Balance educational content with task completion. When providing insights, you may exceed typical length constraints, but remain focused and relevant.
35
+ Insight blocks are the one exception to the "Be concise in your responses" rule; keep everything outside them concise.
36
+
37
+ # Explanatory Style Active
38
+ ${EXPLANATORY_FEATURE_PROMPT}`;
39
+
40
+ export const EXPLANATORY_REMINDER = "<system-reminder>\nExplanatory output style is active. Remember to follow the specific guidelines for this style.\n</system-reminder>";
41
+
42
+ export const SIMPLIFIED_TECHNICAL_ENGLISH_SECTION = `You are an interactive CLI tool that helps users with software engineering tasks.
43
+
44
+ Write all English in ASD-STE100 Simplified Technical English. STE is a controlled
45
+ language. The aerospace industry built it so that a reader who cannot ask a follow-up
46
+ question still reads the text one way only. Its rules are countable, so check your
47
+ prose against them as you write it.
48
+
49
+ ## Precedence
50
+
51
+ These rules set the default shape of the English you write. Any more specific
52
+ instruction takes precedence on whatever it addresses. This includes an instruction
53
+ from the user, from project instructions, from an invoked skill, or from an established
54
+ convention in the file you edit. Where the more specific instruction is silent, these
55
+ rules apply.
56
+
57
+ Follow the more specific instruction without comment. Do not cite this style as a
58
+ reason to override it. Do not ask permission.
59
+
60
+ This exception applies to an explicit instruction only. Do not relax these rules
61
+ because a topic feels casual or because other prose seems friendlier.
62
+
63
+ ## Never apply these rules to
64
+
65
+ - Code. This includes identifiers, syntax, and string literals.
66
+ - Quoted material. This includes error output, command output, file contents, and
67
+ another person's words. To rewrite a quotation is falsification, not simplification.
68
+ - Text where the exact wording carries the meaning. This includes a command to run, an
69
+ API name, a config key, and an exact error string.
70
+
71
+ ## Rules
72
+
73
+ | Rule | Limit |
74
+ | --- | --- |
75
+ | Noun clusters | Maximum 3 words stacked as a modifier. Break a longer stack apart and name the relationship. |
76
+ | Main clause first | State the subject and the main verb before any qualifier. Move a relative clause to after the main verb where you can. |
77
+ | Sentence length | Maximum 20 words for an instruction or a procedure. Maximum 25 words for descriptive text. |
78
+ | One instruction per sentence | Do not join two instructions with "and" or "then". |
79
+ | Active voice | Use the passive voice in descriptive text only, and only when the actor is unknown or irrelevant. |
80
+ | Simple tenses only | Use the infinitive, the imperative, the simple present, the simple past, and the simple future. Use a past participle as an adjective only. Do not use the present perfect, the past perfect, or a compound auxiliary. |
81
+ | No \`-ing\` verb forms | Use an \`-ing\` word as a technical noun, or as part of one, only. |
82
+ | No hedge stacking | Do not chain modal verbs, as in "may have been caused by". State the uncertainty as its own plain sentence: "The cause is not confirmed." |
83
+ | One word, one meaning | Use one term for one concept and repeat it. Do not rotate synonyms for the same idea. |
84
+ | Plainest available word | Prefer the short common word to the formal or rare word. |
85
+ | Define domain terms | Define a term that is not common English at first use. Do not carry undefined shorthand forward. |
86
+ | No ellipsis | Keep the subject, the verb, and the article explicit, even when the sentence reads longer. |
87
+ | Paragraphs | One topic. Maximum 6 sentences. |
88
+ | Vertical lists | Use a numbered or bulleted list for 3 or more steps or conditions. |
89
+
90
+ ## Project vocabulary
91
+
92
+ STE permits a project to define its own approved vocabulary of technical nouns and
93
+ verbs. A \`CONTEXT.md\` file at a repository root is that vocabulary.
94
+
95
+ If the project has a \`CONTEXT.md\`, use its terms exactly as it defines them, in the
96
+ part of speech it defines. Never substitute a synonym for a term it defines. Never use
97
+ a word that its \`_Avoid_\` lines reject. Do not redefine its terms inline, because the
98
+ glossary is the definition.
99
+
100
+ If the project has no \`CONTEXT.md\`, do not invent one. Do not present any term as
101
+ already established. The rules above apply without change: define a term at first use,
102
+ prefer the plainest word, and use one term for one concept.
103
+
104
+ ## Length is not terseness
105
+
106
+ The caps apply to each sentence, not to the response. Clarity is the goal, not
107
+ concision. A long answer in short sentences is correct.
108
+
109
+ Never drop a fact, a condition, a caveat, or a scope qualifier to meet a limit. Split
110
+ the sentence instead.`;
111
+
112
+ export const SIMPLIFIED_TECHNICAL_ENGLISH_REMINDER = "<system-reminder>\nSimplified Technical English output style is active. Remember to follow the specific guidelines for this style.\n</system-reminder>";
113
+
114
+ export const OUTPUT_STYLES: Readonly<Record<OutputStyleId, OutputStyle>> = {
115
+ default: {
116
+ label: "Default",
117
+ description: "pi completes coding tasks efficiently and concisely",
118
+ },
119
+ explanatory: {
120
+ label: "Explanatory",
121
+ description: "pi explains its implementation choices and codebase patterns",
122
+ prompt: { section: EXPLANATORY_SECTION, reminder: EXPLANATORY_REMINDER },
123
+ },
124
+ simplified: {
125
+ label: "Simplified Technical English",
126
+ description: "pi writes simple prose in Simplified Technical English",
127
+ prompt: { section: SIMPLIFIED_TECHNICAL_ENGLISH_SECTION, reminder: SIMPLIFIED_TECHNICAL_ENGLISH_REMINDER },
128
+ },
129
+ };
130
+
131
+ export function isOutputStyleId(value: unknown): value is OutputStyleId {
132
+ return typeof value === "string" && OUTPUT_STYLE_IDS.some(id => id === value);
133
+ }
134
+
135
+ /** Map the old pi-topping-insights style spelling to the current lowercase id. */
136
+ export function fromLegacyOutputStyle(value: unknown): OutputStyleId | undefined {
137
+ if (value === "default") return "default";
138
+ if (value === "Explanatory") return "explanatory";
139
+ return undefined;
140
+ }
141
+
142
+ /**
143
+ * Apply a style's prompt section and return its hidden reminder. Default removes the section only
144
+ * when it holds one of these styles' text, so another extension's section survives. Hosts without
145
+ * structured prompt sections get no change.
146
+ */
147
+ export function applyOutputStyle(
148
+ sections: Record<string, string> | undefined,
149
+ id: OutputStyleId,
150
+ ): BeforeAgentStartEventResult | undefined {
151
+ if (!sections) return undefined;
152
+
153
+ const style = OUTPUT_STYLES[id];
154
+ if (!style.prompt) {
155
+ if (OUTPUT_STYLE_IDS.some(styleId => {
156
+ const ownedSection = OUTPUT_STYLES[styleId].prompt?.section;
157
+ return ownedSection !== undefined && ownedSection === sections[OUTPUT_STYLE_SECTION];
158
+ })) {
159
+ delete sections[OUTPUT_STYLE_SECTION];
160
+ }
161
+ return undefined;
162
+ }
163
+
164
+ sections[OUTPUT_STYLE_SECTION] = style.prompt.section;
165
+ return {
166
+ message: {
167
+ customType: OUTPUT_STYLE_MESSAGE_TYPE,
168
+ content: style.prompt.reminder,
169
+ display: false,
170
+ },
171
+ };
172
+ }
package/src/preview.ts CHANGED
@@ -4,8 +4,10 @@ import { ActivityMeter, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN, ra
4
4
  import { DEFAULT_WORKING_WORD, dimAttribute, ELAPSED_INTERVAL_MS, formatTokenRate, getThinkingLevelColorizer, METER_INTERVAL_MS, SHIMMER_INTERVAL_MS, SPINNER_FRAME_MS } from "./format.ts";
5
5
  import { buildLoaderMessage } from "./loader-message.ts";
6
6
  import { getResponseModelColorizer } from "./nvidia-green.ts";
7
+ import { isOutputStyleId, OUTPUT_STYLES } from "./output-style.ts";
7
8
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, buildPromptBoxLines } from "./prompt-decorator.ts";
8
- import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleMeterUpperBound, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.ts";
9
+ import { formatServiceTierSummary } from "./service-tier.ts";
10
+ import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleMeterUpperBound, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, OUTPUT_STYLE_ID, parseLoaderOrder } from "./settings.ts";
9
11
  import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, wordPacksPath, type WordPack } from "./word-packs.ts";
10
12
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
11
13
  // flat 28 tps for the token readouts.
@@ -32,6 +34,10 @@ export class PreviewRenderer {
32
34
  if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
33
35
  if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
34
36
  if (activeItemId === "useNerdFont") return { lines: ["", `Icon preview: ${values.useNerdFont ? "" : "π"}`, ""] };
37
+ if (activeItemId === OUTPUT_STYLE_ID) {
38
+ const id = isOutputStyleId(values[OUTPUT_STYLE_ID]) ? values[OUTPUT_STYLE_ID] : DEFAULT_SETTINGS.outputStyle;
39
+ return { lines: ["", OUTPUT_STYLES[id].description, this.#ctx.ui.theme.fg("dim", "Applies from your next message")] };
40
+ }
35
41
  if (activeItemId === "showResponseModelFooter") {
36
42
  if (values.showResponseModelFooter !== true) return { lines: ["", "Response model footer is disabled.", ""] };
37
43
  const color = isThinkingLevelColor(values.responseModelColor) ? values.responseModelColor : DEFAULT_SETTINGS.decorations.responseModelColor;
@@ -143,6 +149,7 @@ export class PreviewRenderer {
143
149
  const details: string[] = [];
144
150
  if (values.doneMarkerTokens === true) details.push("↓ 949 tokens");
145
151
  if (values.doneMarkerInputs === true) details.push("2 mid-turn inputs");
152
+ if (values.doneMarkerServiceTier === true) details.push(formatServiceTierSummary([{ tier: "flex", calls: 3 }]));
146
153
  let model = "";
147
154
  if (values.doneMarkerModel === true) {
148
155
  const color = isThinkingLevelColor(values.doneMarkerModelColor) ? values.doneMarkerModelColor : DEFAULT_SETTINGS.decorations.doneMarkerModelColor;
@@ -1,4 +1,4 @@
1
- import type { MessageRenderer } from "@earendil-works/pi-coding-agent";
1
+ import type { MessageRenderer, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import { truncateToWidth, visibleWidth, wrapTextWithAnsi } 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";
@@ -41,7 +41,7 @@ type PromptTheme = {
41
41
 
42
42
  const COMPLETION_MARKER_TRAIL_RUNS = [6, 4, 2, 1] as const;
43
43
 
44
- /** Assemble the completion-marker content: an optional icon, then the dim `word for elapsed (details)` summary, then an optional pre-styled model suffix. */
44
+ /** Assemble the completion summary with an optional word color and pre-styled model suffix. */
45
45
  export function buildCompletionMarkerContent(
46
46
  theme: PromptTheme,
47
47
  icon: string,
@@ -49,9 +49,12 @@ export function buildCompletionMarkerContent(
49
49
  elapsed: string,
50
50
  details: string[],
51
51
  model = "",
52
+ wordColor: ThemeColor = "dim",
52
53
  ): string {
53
54
  const tail = details.length ? ` (${details.join(" · ")})` : "";
54
- const summary = theme.fg("dim", `${icon ? " " : ""}${word} for ${elapsed}${tail}`);
55
+ const summary = wordColor === "dim"
56
+ ? theme.fg("dim", `${icon ? " " : ""}${word} for ${elapsed}${tail}`)
57
+ : `${icon ? theme.fg("dim", " ") : ""}${theme.fg(wordColor, word)}${theme.fg("dim", ` for ${elapsed}${tail}`)}`;
55
58
  return `${icon}${summary}${model ? `${theme.fg("dim", " · ")}${model}` : ""}`;
56
59
  }
57
60
 
@@ -96,7 +99,6 @@ export function buildPromptBoxLines(
96
99
  const thinkingLevel = isThinkingLevel(options.thinkingLevel) ? options.thinkingLevel : undefined;
97
100
  const border = getThinkingLevelColorizer(theme, borderColor, thinkingLevel);
98
101
  const iconColor = (text: string) => theme.fg("text", text);
99
- const label = (text: string) => theme.fg("customMessageLabel", text);
100
102
  const muted = (text: string) => theme.fg("dim", text);
101
103
  const bodyText = (text: string) => theme.fg("customMessageText", text);
102
104
  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
@@ -1,6 +1,8 @@
1
1
  import type {
2
2
  AgentSettledEvent,
3
3
  AgentStartEvent,
4
+ BeforeAgentStartEvent,
5
+ BeforeAgentStartEventResult,
4
6
  CustomEntry,
5
7
  ExtensionAPI,
6
8
  ExtensionCommandContext,
@@ -14,6 +16,7 @@ import type {
14
16
  } from "@earendil-works/pi-coding-agent";
15
17
  import { Text, type Component } from "@earendil-works/pi-tui";
16
18
  import { ActivityMeter, rateToLevel, TokRateTracker } from "./activity-meter.ts";
19
+ import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./autorouter-feed.ts";
17
20
  import {
18
21
  dimAttribute,
19
22
  getThinkingLevelColorizer,
@@ -39,6 +42,7 @@ import {
39
42
  } from "./format.ts";
40
43
  import { buildLoaderMessage } from "./loader-message.ts";
41
44
  import { showMenu } from "./menu.ts";
45
+ import { applyOutputStyle, OUTPUT_STYLES } from "./output-style.ts";
42
46
  import { getResponseModelColorizer } from "./nvidia-green.ts";
43
47
  import { registerSetupCommand } from "./setup-command.ts";
44
48
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
@@ -46,7 +50,9 @@ import { announcesEmbedded, STATUSLINE_EMBED_CHANNEL } from "./statusline-embed.
46
50
  import { announceMissingToppingsOnce, type MissingToppingsEntryData, renderMissingToppingsEntry, SETUP_ENTRY_TYPE } from "./toppings.ts";
47
51
  import { PreviewRenderer } from "./preview.ts";
48
52
  import { buildCompletionMarkerContent, buildCompletionMarkerLine, PROMPT_BOX_TYPE, promptBoxRenderer, type PromptBoxDetails } from "./prompt-decorator.ts";
49
- import { isPlainObject, modelsResemble, stripControlChars } from "./util.ts";
53
+ import { isPlainObject, modelsResemble, sanitizedText, stripControlChars } from "./util.ts";
54
+ import { formatServiceTierSummary, recordServiceTierCall, serviceTierCountsOf, serviceTierOf, type ServiceTierCount } from "./service-tier.ts";
55
+ import { servedModelOf } from "./served-model.ts";
50
56
  import { loadBundledWordPacks, loadUserWordPacks, pickWorkingTextSelection, type WorkingTextSelection, type WordPack } from "./word-packs.ts";
51
57
 
52
58
  type MessageStartEvent = Extract<ExtensionEvent, { type: "message_start" }>;
@@ -55,10 +61,13 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
55
61
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
56
62
  type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
57
63
  type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
64
+ type ProviderStreamEvent = Extract<ExtensionEvent, { type: "provider_stream_event" }>;
58
65
  type UIPromptKind = UIPromptStartEvent["kind"];
59
66
 
60
67
  const DONE_ENTRY_TYPE = "pi-topping-done";
61
68
  const RESPONSE_MODEL_STATUS_KEY = "pi-topping-response-model";
69
+ /** Pi's `VIRTUAL_MODEL_API` for extension-registered virtual models; Pi does not export it from its public index. */
70
+ const VIRTUAL_MODEL_API = "pi-virtual";
62
71
  const WAITING_LABELS: Record<UIPromptKind, string> = {
63
72
  select: "Waiting for selection",
64
73
  confirm: "Waiting for confirmation",
@@ -73,14 +82,31 @@ const TOKEN_RATE_HOLD_MS = 1_500;
73
82
  const TOKEN_RATE_FADE_MS = 250;
74
83
 
75
84
  interface DoneEntryData {
85
+ aborted?: boolean;
76
86
  word: string;
77
87
  elapsedMs: number;
78
88
  tokens?: number;
79
89
  midTurnInputs?: number;
90
+ serviceTiers?: ServiceTierCount[];
80
91
  thinkingLevel?: ThinkingLevel;
81
92
  model?: string;
82
93
  }
83
94
 
95
+ /** A response model as one source reports it, for `updateResponseModel`. */
96
+ interface ResponseModelReport {
97
+ raw: unknown;
98
+ /**
99
+ * True for a bare routed model id (a router announcement or, under a virtual selection, the
100
+ * message's `model`) rather than a provider-reported value. A weak value never displaces a
101
+ * decorated form of itself the provider already reported, such as a dated name.
102
+ */
103
+ weak: boolean;
104
+ /** The message's own provider when `raw` is its `model`, to recognize a failed route. */
105
+ provider?: string;
106
+ backend?: string;
107
+ routedId?: string;
108
+ }
109
+
84
110
  interface SessionState {
85
111
  startTime: number;
86
112
  workingText: WorkingTextSelection;
@@ -92,6 +118,12 @@ interface SessionState {
92
118
  lastTokenRateSampledAt: number;
93
119
  lastTokenRateTotal: number;
94
120
  responseModel: string;
121
+ served: { model: string; backend: string; routedId: string } | null;
122
+ /** The backend companion to the last raw response-model report. */
123
+ lastResponseModelBackend: string | undefined | typeof NOT_SENT;
124
+ /** The tier reported by the in-flight provider call, added to counts at its assistant message_end. */
125
+ pendingServiceTier: string;
126
+ serviceTiers: ServiceTierCount[];
95
127
  /** Last raw responseModel value seen, or the NOT_SENT sentinel before the first call. */
96
128
  lastResponseModelRaw: unknown;
97
129
  responseModelHoldTimer: ReturnType<typeof setTimeout> | null;
@@ -134,6 +166,10 @@ function makeFreshState(): SessionState {
134
166
  lastTokenRateSampledAt: 0,
135
167
  lastTokenRateTotal: 0,
136
168
  responseModel: "",
169
+ served: null,
170
+ lastResponseModelBackend: NOT_SENT,
171
+ pendingServiceTier: "",
172
+ serviceTiers: [],
137
173
  lastResponseModelRaw: NOT_SENT,
138
174
  responseModelHoldTimer: null,
139
175
  responseModelFadeTimer: null,
@@ -155,10 +191,6 @@ function waitingLabel(waiting: NonNullable<SessionState["waiting"]>): string {
155
191
  return title ? `Waiting: ${title}` : WAITING_LABELS[waiting.kind];
156
192
  }
157
193
 
158
- function responseModelOf(message: unknown): unknown {
159
- return isPlainObject(message) ? message.responseModel : undefined;
160
- }
161
-
162
194
  /** Owns mutable extension state and Pi lifecycle registrations. */
163
195
  export class SessionManager {
164
196
  #counter = new StreamingWordCounter();
@@ -193,13 +225,21 @@ export class SessionManager {
193
225
  }
194
226
  };
195
227
 
228
+ /** Unlike the UI handlers, this runs in every mode: the output style shapes the model's prompt. */
229
+ #onBeforeAgentStart = (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined =>
230
+ applyOutputStyle(event.systemPromptOptions?.sections, this.#settings.outputStyle);
231
+
196
232
  #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
197
233
  this.#currentCtx = ctx;
198
234
  if (!this.usable(ctx)) return;
199
235
 
200
236
  if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
201
237
  else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
202
- if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
238
+ if (
239
+ this.#settings.decorations.decorateUserPrompt &&
240
+ OUTPUT_STYLES[this.#settings.outputStyle].prompt === undefined &&
241
+ this.shouldDecorate(event)
242
+ ) {
203
243
  try {
204
244
  await this.#pi.sendMessage<PromptBoxDetails>(
205
245
  {
@@ -246,7 +286,7 @@ export class SessionManager {
246
286
  if (this.usable(ctx) && event.message.role === "assistant") {
247
287
  this.#state.liveTokens = 0;
248
288
  this.#counter.reset();
249
- this.updateResponseModel(responseModelOf(event.message));
289
+ this.updateResponseModel(this.responseModelOf(event.message));
250
290
  }
251
291
  };
252
292
 
@@ -260,14 +300,15 @@ export class SessionManager {
260
300
  ) {
261
301
  this.#state.liveTokens += this.#counter.count(assistantEvent.delta, assistantEvent.type);
262
302
  }
263
- const rawResponseModel = (assistantEvent as { partial?: { responseModel?: unknown } } | undefined)?.partial?.responseModel
264
- ?? responseModelOf(event.message);
303
+ const rawResponseModel = this.responseModelOf((assistantEvent as { partial?: unknown } | undefined)?.partial)
304
+ ?? this.responseModelOf(event.message);
265
305
  this.updateResponseModel(rawResponseModel);
266
306
  };
267
307
 
268
308
  #onMessageEnd = async (event: MessageEndEvent, ctx: ExtensionContext): Promise<void> => {
269
309
  this.#currentCtx = ctx;
270
310
  if (!this.usable(ctx) || event.message.role !== "assistant") return;
311
+ this.commitServiceTierCall();
271
312
 
272
313
  const rawTokens = event.message.usage?.output;
273
314
  const exactTokens =
@@ -282,9 +323,25 @@ export class SessionManager {
282
323
  }
283
324
  this.#state.liveTokens = 0;
284
325
  this.#counter.reset();
285
- this.updateResponseModel(responseModelOf(event.message));
326
+ this.updateResponseModel(this.responseModelOf(event.message));
286
327
  };
287
328
 
329
+ #onProviderStreamEvent = (event: ProviderStreamEvent, ctx: ExtensionContext): void => {
330
+ if (!this.usable(ctx) || !this.#state.busy) return;
331
+ const tier = serviceTierOf(event.data);
332
+ if (tier) this.#state.pendingServiceTier = tier;
333
+ const served = servedModelOf(event.data);
334
+ if (served) {
335
+ this.updateResponseModel({ raw: served.model, weak: false, provider: event.provider, backend: served.backend });
336
+ }
337
+ };
338
+
339
+ private commitServiceTierCall(): void {
340
+ if (!this.#state.pendingServiceTier) return;
341
+ recordServiceTierCall(this.#state.serviceTiers, this.#state.pendingServiceTier);
342
+ this.#state.pendingServiceTier = "";
343
+ }
344
+
288
345
  #onToolExecutionStart = async (_e: ToolExecutionStartEvent, ctx: ExtensionContext): Promise<void> => {
289
346
  this.#currentCtx = ctx;
290
347
  if (this.usable(ctx)) {
@@ -311,9 +368,10 @@ export class SessionManager {
311
368
  this.tick();
312
369
  };
313
370
 
314
- #onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
371
+ #onAgentSettled = async (e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
315
372
  this.#currentCtx = ctx;
316
373
  if (!this.usable(ctx)) return;
374
+ this.commitServiceTierCall();
317
375
 
318
376
  const hadPrompt = !!this.#state.startTime;
319
377
  const elapsedMs = hadPrompt ? Date.now() - this.#state.startTime : 0;
@@ -333,11 +391,14 @@ export class SessionManager {
333
391
  resetTokenRateState(this.#state);
334
392
  this.#state.lastMessage = NOT_SENT;
335
393
  if (this.#settings.features.doneMarker && hadPrompt) {
394
+ const aborted = e.aborted === true;
336
395
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
337
- word: this.#settings.features.randomizeDoneMarker ? this.#state.workingText.pastTense : "Worked",
396
+ ...(aborted ? { aborted: true } : {}),
397
+ word: aborted ? "Interrupted" : this.#settings.features.randomizeDoneMarker ? this.#state.workingText.pastTense : "Worked",
338
398
  elapsedMs,
339
399
  tokens: this.#state.confirmTokens,
340
400
  midTurnInputs: this.#state.midTurnInputs,
401
+ ...(this.#state.serviceTiers.length ? { serviceTiers: this.#state.serviceTiers.map((count) => ({ ...count })) } : {}),
341
402
  thinkingLevel: ctx.thinkingLevel,
342
403
  model: this.#state.responseModel || undefined,
343
404
  });
@@ -354,6 +415,7 @@ export class SessionManager {
354
415
 
355
416
  #renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
356
417
  if (!entry.data) return undefined;
418
+ const aborted = entry.data.aborted === true;
357
419
  const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
358
420
  const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
359
421
  const features = this.#settings.features;
@@ -362,16 +424,20 @@ export class SessionManager {
362
424
  if (features.doneMarkerInputs && typeof entry.data.midTurnInputs === "number" && Number.isInteger(entry.data.midTurnInputs) && entry.data.midTurnInputs > 0) {
363
425
  details.push(`${entry.data.midTurnInputs} mid-turn input${entry.data.midTurnInputs === 1 ? "" : "s"}`);
364
426
  }
427
+ if (features.doneMarkerServiceTier) {
428
+ const summary = formatServiceTierSummary(serviceTierCountsOf(entry.data.serviceTiers));
429
+ if (summary) details.push(summary);
430
+ }
365
431
  const icon = features.doneMarkerIcon ? theme.fg("text", this.#settings.decorations.useNerdFont ? "" : "π") : "";
366
432
  const thinkingLevel = isThinkingLevel(entry.data.thinkingLevel) ? entry.data.thinkingLevel : undefined;
367
- const rawModel = typeof entry.data.model === "string" ? stripControlChars(entry.data.model).trim() : "";
433
+ const rawModel = sanitizedText(entry.data.model);
368
434
  const modelColored = features.doneMarkerModel && rawModel
369
435
  ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
370
436
  : "";
371
437
  const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
372
- const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model);
438
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model, aborted ? "warning" : "dim");
373
439
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
374
- const borderColor = this.#settings.decorations.doneMarkerBorderColor;
440
+ const borderColor = aborted ? "warning" : this.#settings.decorations.doneMarkerBorderColor;
375
441
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
376
442
  if (borderStyle === "none") return new Text(markerContent);
377
443
 
@@ -394,11 +460,13 @@ export class SessionManager {
394
460
 
395
461
  install(): void {
396
462
  this.#pi.on("session_start", this.#onSessionStart);
463
+ this.#pi.on("before_agent_start", this.#onBeforeAgentStart);
397
464
  this.#pi.on("input", this.#onInput);
398
465
  this.#pi.on("agent_start", this.#onAgentStart);
399
466
  this.#pi.on("message_start", this.#onMessageStart);
400
467
  this.#pi.on("message_update", this.#onMessageUpdate);
401
468
  this.#pi.on("message_end", this.#onMessageEnd);
469
+ this.#pi.on("provider_stream_event", this.#onProviderStreamEvent);
402
470
  this.#pi.on("tool_execution_start", this.#onToolExecutionStart);
403
471
  this.#pi.on("agent_settled", this.#onAgentSettled);
404
472
  this.#pi.on("ui_prompt_start", this.#onUIPromptStart);
@@ -407,11 +475,18 @@ export class SessionManager {
407
475
  this.#pi.events.on(STATUSLINE_EMBED_CHANNEL, (data) => {
408
476
  this.#loaderInStatusline = announcesEmbedded(data);
409
477
  });
478
+ this.#pi.events.on(ROUTED_MODEL_CHANNEL, (data) => {
479
+ // Direct routes serve compaction and other extensions' side requests; a route while idle
480
+ // is such a background request too, and must not leave a stale model for the next turn.
481
+ const announcement = routedModelAnnouncement(data);
482
+ if (!announcement || announcement.reason === "direct" || !this.#state.busy) return;
483
+ this.updateResponseModel({ raw: announcement.modelId, weak: true, provider: announcement.provider, routedId: announcement.modelId });
484
+ });
410
485
  this.#pi.registerEntryRenderer<DoneEntryData>(DONE_ENTRY_TYPE, (entry, _o, theme) => this.#renderDoneEntry(entry, theme));
411
486
  this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
412
487
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
413
488
  this.#pi.registerCommand("topping-settings", {
414
- description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
489
+ description: "Configure the prompt box, working loader, completion marker, word packs, and output style.",
415
490
  handler: async (_a, ctx) => this.showSettings(ctx),
416
491
  });
417
492
  registerSetupCommand(this.#pi);
@@ -475,25 +550,63 @@ export class SessionManager {
475
550
  }
476
551
  }
477
552
 
478
- private updateResponseModel(raw: unknown): void {
479
- if (raw === this.#state.lastResponseModelRaw) return;
480
- this.#state.lastResponseModelRaw = raw;
481
- const responseModel = typeof raw === "string" ? stripControlChars(raw).trim() : "";
482
- const selectedModel = this.#currentCtx?.model?.id;
483
- if (responseModel && modelsResemble(selectedModel, responseModel)) {
553
+ /**
554
+ * The response model a message reports. Under a virtual selection Pi stamps the routed
555
+ * physical model on the message's `model` instead, so fall back to that as a weak value;
556
+ * a failed route leaves the virtual model itself there, which `restatesSelection` hides.
557
+ */
558
+ private responseModelOf(message: unknown): ResponseModelReport | undefined {
559
+ if (!isPlainObject(message)) return undefined;
560
+ if (message.responseModel !== undefined) {
561
+ return { raw: message.responseModel, weak: false, routedId: sanitizedText(message.model) || undefined };
562
+ }
563
+ if (this.#currentCtx?.model?.api !== VIRTUAL_MODEL_API) return undefined;
564
+ return { raw: message.model, weak: true, provider: typeof message.provider === "string" ? message.provider : undefined };
565
+ }
566
+
567
+ /**
568
+ * Whether a response model merely restates the selected model. Under a virtual selection
569
+ * the routed model is always another model, so only a failed route, which leaves the
570
+ * virtual model itself on the message, is hidden; a resemblance check would also hide a
571
+ * routed model whose id contains the virtual id, such as openrouter's `auto`.
572
+ */
573
+ private restatesSelection(report: ResponseModelReport, responseModel: string): boolean {
574
+ const selected = this.#currentCtx?.model;
575
+ if (!selected) return false;
576
+ if (selected.api === VIRTUAL_MODEL_API) return report.provider === selected.provider && responseModel === selected.id;
577
+ return modelsResemble(selected.id, responseModel);
578
+ }
579
+
580
+ private updateResponseModel(report: ResponseModelReport | undefined): void {
581
+ const backendReport = report?.backend;
582
+ if (report?.raw === this.#state.lastResponseModelRaw && backendReport === this.#state.lastResponseModelBackend) return;
583
+ this.#state.lastResponseModelRaw = report?.raw;
584
+ this.#state.lastResponseModelBackend = backendReport;
585
+ if (!report) return;
586
+ const model = sanitizedText(report.raw);
587
+ if (!model) return;
588
+ const backend = report.backend ?? (model === this.#state.served?.model ? this.#state.served.backend : "");
589
+ const showBackend = !!backend && backend !== report.provider;
590
+ const restates = this.restatesSelection(report, model);
591
+ const display = restates ? (showBackend ? `via ${backend}` : "") : (showBackend ? `${model} via ${backend}` : model);
592
+ if (!display) {
484
593
  if (this.#state.responseModel) {
485
594
  this.#state.responseModel = "";
486
595
  this.stopResponseModelSlide();
487
596
  this.tick();
488
597
  }
598
+ this.#state.served = null;
489
599
  return;
490
600
  }
491
- if (responseModel && responseModel !== this.#state.responseModel) {
601
+ if (report.weak && model === this.#state.served?.routedId) return;
602
+ const changed = display !== this.#state.responseModel;
603
+ if (changed) {
492
604
  // A model replacing one already shown swaps in place; only an appearing one slides out.
493
605
  if (!this.#state.responseModel) this.startResponseModelSlide();
494
- this.#state.responseModel = responseModel;
495
- this.tick();
606
+ this.#state.responseModel = display;
496
607
  }
608
+ this.#state.served = { model, backend, routedId: report.routedId ?? model };
609
+ if (changed) this.tick();
497
610
  }
498
611
 
499
612
  /**
@@ -601,7 +714,11 @@ export class SessionManager {
601
714
  state.confirmTokens = 0;
602
715
  state.liveTokens = 0;
603
716
  state.responseModel = "";
717
+ state.served = null;
718
+ state.pendingServiceTier = "";
719
+ state.serviceTiers = [];
604
720
  state.lastResponseModelRaw = NOT_SENT;
721
+ state.lastResponseModelBackend = NOT_SENT;
605
722
  state.activityMeter.reset();
606
723
  resetTokenRateState(state);
607
724
  state.midTurnInputs = 0;
@@ -688,6 +805,7 @@ export class SessionManager {
688
805
  this.#userPacks = loadUserWordPacks();
689
806
  this.#allPacks = [...this.#bundledPacks, ...this.#userPacks];
690
807
  const before = this.indicatorFingerprint();
808
+ const styleBefore = this.#settings.outputStyle;
691
809
  const preview = new PreviewRenderer(ctx, this.#allPacks);
692
810
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
693
811
  title: "Pi Topping: Settings",
@@ -717,6 +835,9 @@ export class SessionManager {
717
835
  this.startTimer();
718
836
  this.tick();
719
837
  }
720
- ctx.ui.notify("Pi Topping settings saved", "info");
838
+ const notification = styleBefore === this.#settings.outputStyle
839
+ ? "Pi Topping settings saved"
840
+ : `Pi Topping settings saved · ${OUTPUT_STYLES[this.#settings.outputStyle].label} output style applies from your next message`;
841
+ ctx.ui.notify(notification, "info");
721
842
  }
722
843
  }
package/src/settings.ts CHANGED
@@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "
3
3
  import { dirname, join } from "node:path";
4
4
  import { DEFAULT_METER_UPPER_BOUND_TPS, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN } from "./activity-meter.ts";
5
5
  import { DEFAULT_LOADER_ORDER, type LoaderElement } from "./format.ts";
6
+ import { fromLegacyOutputStyle, isOutputStyleId, OUTPUT_STYLE_IDS, OUTPUT_STYLES, type OutputStyleId } from "./output-style.ts";
6
7
  import type { MenuNumberEntry, MenuSection } from "./menu.ts";
7
8
  import { isWordPackEnabled, isWordPackId, WORD_PACK_MENU_PREFIX, type WordPack } from "./word-packs.ts";
8
9
  import { isPlainObject } from "./util.ts";
@@ -119,22 +120,27 @@ export interface DecoratorSettings {
119
120
  randomizeDoneMarker: boolean;
120
121
  doneMarkerTokens: boolean;
121
122
  doneMarkerInputs: boolean;
123
+ doneMarkerServiceTier: boolean;
122
124
  doneMarkerModel: boolean;
123
125
  includeDefaultWorkingText: boolean;
124
126
  };
125
127
  loaderOrder: LoaderElement[];
126
128
  wordPacks: Record<string, boolean>;
129
+ outputStyle: OutputStyleId;
127
130
  }
128
131
 
129
132
  export const DEFAULT_SETTINGS: DecoratorSettings = {
130
133
  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 },
134
+ 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
135
  loaderOrder: [...DEFAULT_LOADER_ORDER],
133
136
  wordPacks: {},
137
+ outputStyle: "default",
134
138
  };
135
139
 
136
140
  /** Menu key carrying the loader element order as a comma-joined list of element ids. */
137
141
  export const LOADER_ORDER_ID = "loaderOrder";
142
+ /** Menu key for the output style cycle row. */
143
+ export const OUTPUT_STYLE_ID = "outputStyle";
138
144
  const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
139
145
  spinner: "Animated spinner",
140
146
  text: "“Working” text",
@@ -162,6 +168,19 @@ export function parseLoaderOrder(value: unknown): LoaderElement[] {
162
168
  }
163
169
 
164
170
  export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
171
+
172
+ /** pi-topping-insights' saved style, honored until pi-topping's own settings record one. */
173
+ function legacyOutputStyle(): OutputStyleId {
174
+ try {
175
+ const parsed: unknown = JSON.parse(readFileSync(join(getAgentDir(), "pi-topping-insights", "settings.json"), "utf8"));
176
+ return (isPlainObject(parsed) ? fromLegacyOutputStyle(parsed.style) : undefined) ?? DEFAULT_SETTINGS.outputStyle;
177
+ } catch { return DEFAULT_SETTINGS.outputStyle; }
178
+ }
179
+
180
+ function defaultSettings(): DecoratorSettings {
181
+ return { ...structuredClone(DEFAULT_SETTINGS), outputStyle: legacyOutputStyle() };
182
+ }
183
+
165
184
  function mergeGroup<T extends Record<string, boolean | string | number>>(defaults: T, parsed: unknown): T {
166
185
  const merged = { ...defaults };
167
186
  if (!isPlainObject(parsed)) return merged;
@@ -231,6 +250,7 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
231
250
  { id: "randomizeDoneMarker", label: "Randomize “Worked” text", section: "Completion Marker", group: "features", key: "randomizeDoneMarker" },
232
251
  { id: "doneMarkerTokens", label: "Tokens spent", section: "Completion Marker", group: "features", key: "doneMarkerTokens" },
233
252
  { id: "doneMarkerInputs", label: "Mid-turn inputs", section: "Completion Marker", group: "features", key: "doneMarkerInputs" },
253
+ { id: "doneMarkerServiceTier", label: "Service tier", section: "Completion Marker", group: "features", key: "doneMarkerServiceTier" },
234
254
  { id: "doneMarkerModel", label: "Response model", section: "Completion Marker", group: "features", key: "doneMarkerModel" },
235
255
  { id: "doneMarkerModelColor", label: "Response model color", section: "Completion Marker", group: "decorations", key: "doneMarkerModelColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS },
236
256
  { id: "doneMarkerModelDimmed", label: "Response model dimmed", section: "Completion Marker", group: "decorations", key: "doneMarkerModelDimmed" },
@@ -311,6 +331,10 @@ export function buildMenuSections(settings: DecoratorSettings, bundledPacks: rea
311
331
  ...buildSection("Word Packs", settings).items.map((item, index) => ({ ...item, spacerBefore: index === 0, disabledUnlessAnyOf: packIds })),
312
332
  ],
313
333
  },
334
+ {
335
+ title: "Output Style",
336
+ items: [{ id: OUTPUT_STYLE_ID, label: "Style", value: settings.outputStyle, cycleValues: OUTPUT_STYLE_IDS, cycleValueLabels: Object.fromEntries(OUTPUT_STYLE_IDS.map(id => [id, OUTPUT_STYLES[id].label])) }],
337
+ },
314
338
  buildSection("Options", settings),
315
339
  ];
316
340
  }
@@ -335,6 +359,8 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
335
359
  if (isWordPackId(packId)) next.wordPacks[packId] = value;
336
360
  }
337
361
  if (typeof values[LOADER_ORDER_ID] === "string") next.loaderOrder = parseLoaderOrder(values[LOADER_ORDER_ID]);
362
+ const outputStyle = values[OUTPUT_STYLE_ID];
363
+ if (isOutputStyleId(outputStyle)) next.outputStyle = outputStyle;
338
364
  return next;
339
365
  }
340
366
 
@@ -348,12 +374,13 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
348
374
  * border color is reset. Below 4, the response-model footer inherits the response-model
349
375
  * setting. A missing, non-integer, or out-of-range meter upper bound resolves
350
376
  * to the default of 80 tps. A legacy `features.simCityWorkingText: true` enables the
351
- * `simcity` pack when no explicit preference is stored.
377
+ * `simcity` pack when no explicit preference is stored. A missing or invalid `outputStyle`
378
+ * falls back to `pi-topping-insights/settings.json`'s `style`, then to `default`, without a schema-version gate.
352
379
  */
353
380
  export function loadSettings(): DecoratorSettings {
354
381
  try {
355
382
  const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
356
- if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
383
+ if (!isPlainObject(parsed)) return defaultSettings();
357
384
  const wordPacks: Record<string, boolean> = { ...DEFAULT_SETTINGS.wordPacks };
358
385
  if (isPlainObject(parsed.wordPacks)) {
359
386
  for (const [id, enabled] of Object.entries(parsed.wordPacks)) {
@@ -363,7 +390,9 @@ export function loadSettings(): DecoratorSettings {
363
390
  if (isPlainObject(parsed.features) && parsed.features.simCityWorkingText === true && !("simcity" in wordPacks)) {
364
391
  wordPacks.simcity = true;
365
392
  }
366
- const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks };
393
+ const storedOutputStyle = parsed.outputStyle;
394
+ const outputStyle = isOutputStyleId(storedOutputStyle) ? storedOutputStyle : legacyOutputStyle();
395
+ const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks, outputStyle };
367
396
  if (isPlainObject(parsed.decorations)) {
368
397
  for (const [key, value] of Object.entries(parsed.decorations)) {
369
398
  if (!Object.hasOwn(DEFAULT_SETTINGS.decorations, key) || typeof value !== "string") continue;
@@ -392,7 +421,7 @@ export function loadSettings(): DecoratorSettings {
392
421
  }
393
422
  }
394
423
  return settings;
395
- } catch { return structuredClone(DEFAULT_SETTINGS); }
424
+ } catch { return defaultSettings(); }
396
425
  }
397
426
 
398
427
  export function atomicWriteFile(path: string, contents: string): void {
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;