@underactive/pi-topping 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,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. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection (requires Pi ≥0.99.0). Under a Pi virtual model such as pi-topping-autorouter's `litellm-auto/auto`, the response model is the routed physical model. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
9
+ **“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection (requires Pi ≥0.99.0). Under a Pi virtual model such as pi-topping-routesense's `routesense/auto`, the response model is the routed physical model. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
10
10
 
11
11
  ![Demo of pi-topping's shimmering activity word, scrolling activity meter, elapsed timer, token count, and token rate](https://raw.githubusercontent.com/underactive/pi-topping/main/media/demo.gif)
12
12
 
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.
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
@@ -104,15 +106,30 @@ Run `/topping-settings` (TUI only) to customize your toppings. Settings persist
104
106
  ║ ║
105
107
  ║ [■] Include default/random “Working” text ON ║
106
108
  ║ ║
109
+ ╟─ Output Style ────────────────────────────────────────────────╢
110
+ ║ [■] Style ‹ Default › ║
111
+ ║ ║
107
112
  ╟─ Options ─────────────────────────────────────────────────────╢
108
113
  ║ [■] Use NerdFont icons ON ║
109
114
  ║ [ ] Response model footer OFF ║
110
115
  ╟───────────────────────────────────────────────────────────────╢
111
116
  ║ ↑↓ move PgUp/PgDn page ←→ select ║
112
117
  ║ ␣ toggle ⏎ apply esc cancel ║
113
- ╚═══════════════════════════════════════════════════════[ 9/58 ]╝
118
+ ╚═══════════════════════════════════════════════════════[ 9/59 ]╝
114
119
  ```
115
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
+
116
133
  ### Word packs
117
134
 
118
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`.
@@ -132,6 +149,8 @@ Bundled packs live in [`wordpacks/`](wordpacks/). Copy `wordpacks/doctor-who.jso
132
149
 
133
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.
134
151
 
152
+ ### Colors and loader details
153
+
135
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`.
136
155
 
137
156
  Under **Elements Order**, press `␣` to grab a row, then `↑`/`↓` to slide that element
@@ -148,11 +167,13 @@ restarts the cycle.
148
167
 
149
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.
150
169
 
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.
170
+ The response model is captured from assistant responses and raw Responses-API stream events (Pi ≥0.99.0), displayed as its sanitized value with no label only when it meaningfully differs from the selected model, and defaults to the final loader detail after output tokens. When Ramp Router reports a gateway backend in the raw event, the label can include it as `<model> via <backend>`, or `via <backend>` alone when the served model restates the selection. Under a Pi virtual model such as pi-topping-routesense's `routesense/auto`, it is the routed physical model: taken from the router's `pi-topping-routesense:routed-model` announcement when present, which arrives before the first token (announcements for `direct` routes such as compaction are ignored), and otherwise from the assistant message's model. There only a failed route, which reports the virtual model itself, is hidden, and a bare routed id never displaces a provider-reported decorated form of itself such as a dated name. While pi-topping-statusline embeds the loader in its status bar, the model slides out tail first from behind its separator over 0.3 seconds when it first appears in a run, like that bar's embedded status; elsewhere it appears at once, and a different model replacing it always swaps in place. Equivalent local paths and decorated filenames are suppressed. Its loader display can be hidden, recolored, permanently dimmed, or reordered. The independent **Response model footer** option is disabled by default; when enabled, the final model appears under the editor after settlement, remains visible for 3 seconds, fades through five theme-aware shades over 0.5 seconds, then clears. New work cancels the pending fade. When the active provider is `switchyard`, the loader and footer hold/fade use NVIDIA green (`#84c51a`, matching pi-topping-statusline) regardless of the configured Response model color; the dim setting still applies.
152
171
 
153
172
  ## Requirements
154
173
 
155
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.
156
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.
157
178
  - Node.js ≥22.19.0
158
179
 
@@ -164,7 +185,7 @@ npm test
164
185
  npm run typecheck
165
186
  ```
166
187
 
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.
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.
168
189
 
169
190
  ## Limitations
170
191
 
@@ -172,6 +193,7 @@ Tests cover the word/shimmer rendering, prompt box and completion marker renderi
172
193
  - Elapsed time normally starts when you submit a prompt; for auto-continuations without that event, it starts slightly later instead.
173
194
  - If another extension also customizes the working message, whichever one runs last wins.
174
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).
175
197
 
176
198
  ## Author
177
199
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping",
3
- "version": "0.10.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, and an end-of-turn completion marker",
3
+ "version": "0.11.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, 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.3",
34
- "@earendil-works/pi-tui": "1.0.3",
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,200 @@
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
+ }
173
+
174
+ /**
175
+ * Section value to send when the transcript's folded `output_style` text is `current`.
176
+ * `null` removes the section. `undefined` means the transcript already matches `id`.
177
+ */
178
+ export function outputStyleSectionPatch(current: string | undefined, id: OutputStyleId): string | null | undefined {
179
+ const sections: Record<string, string> = {};
180
+ if (current !== undefined) sections[OUTPUT_STYLE_SECTION] = current;
181
+ applyOutputStyle(sections, id);
182
+ const next = sections[OUTPUT_STYLE_SECTION];
183
+ if (next === current) return undefined;
184
+ return next ?? null;
185
+ }
186
+
187
+ /** Last `output_style` section after replaying system messages. A `null` value clears it. */
188
+ export function foldedOutputStyleSection(
189
+ messages: readonly { role: string; sections?: Record<string, string | null> }[],
190
+ ): string | undefined {
191
+ let current: string | undefined;
192
+ for (const message of messages) {
193
+ if (message.role !== "system") continue;
194
+ const sections = message.sections;
195
+ if (!sections || !Object.prototype.hasOwnProperty.call(sections, OUTPUT_STYLE_SECTION)) continue;
196
+ const value = sections[OUTPUT_STYLE_SECTION];
197
+ current = value === null ? undefined : value;
198
+ }
199
+ return current;
200
+ }
package/src/preview.ts CHANGED
@@ -4,9 +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
9
  import { formatServiceTierSummary } from "./service-tier.ts";
9
- import { DEFAULT_SETTINGS, fromCycleDirection, fromCycleMeterUpperBound, fromCycleSpeed, isBorderStyle, isDoneMarkerBorderColor, isDoneMarkerBorderStyle, isDoneMarkerStyle, isPromptBorderColor, isSpinnerColor, isThinkingLevelColor, LOADER_ORDER_ID, MENU_ENTRIES, parseLoaderOrder } from "./settings.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";
10
11
  import { isWordPackEnabled, selectWorkingTextSelection, WORD_PACK_MENU_PREFIX, wordPacksPath, type WordPack } from "./word-packs.ts";
11
12
  // Simulated load for the menu preview: a 2.4s cosine wave peaking at 46 tps for the meter,
12
13
  // flat 28 tps for the token readouts.
@@ -33,6 +34,10 @@ export class PreviewRenderer {
33
34
  if (PROMPT_IDS.has(activeItemId ?? "")) return this.promptPreview(values, width);
34
35
  if (MARKER_IDS.has(activeItemId ?? "")) return this.markerPreview(values, width);
35
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
+ }
36
41
  if (activeItemId === "showResponseModelFooter") {
37
42
  if (values.showResponseModelFooter !== true) return { lines: ["", "Response model footer is disabled.", ""] };
38
43
  const color = isThinkingLevelColor(values.responseModelColor) ? values.responseModelColor : DEFAULT_SETTINGS.decorations.responseModelColor;
@@ -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
 
@@ -1,7 +1,7 @@
1
1
  import { isPlainObject, sanitizedText } from "./util.ts";
2
2
 
3
3
  /**
4
- * Event-bus channel on which pi-topping-autorouter announces the physical model it routed
4
+ * Event-bus channel on which pi-topping-routesense announces the physical model it routed
5
5
  * a request to, before Pi sends that request (so before `message_start`). Payload:
6
6
  *
7
7
  * ```json
@@ -21,9 +21,9 @@ import { isPlainObject, sanitizedText } from "./util.ts";
21
21
  *
22
22
  * `reason` is Pi's route reason: `user`, `continuation`, `retry`, or `direct`. `direct` routes
23
23
  * serve compaction summaries and other extensions' side requests, not the chat response.
24
- * `tier` may be absent. Shared with pi-topping-autorouter.
24
+ * `tier` may be absent. Shared with pi-topping-routesense.
25
25
  */
26
- export const ROUTED_MODEL_CHANNEL = "pi-topping-autorouter:routed-model";
26
+ export const ROUTED_MODEL_CHANNEL = "pi-topping-routesense:routed-model";
27
27
 
28
28
  const ROUTE_REASONS = ["user", "continuation", "retry", "direct"] as const;
29
29
  export type RouteReason = (typeof ROUTE_REASONS)[number];
package/src/session.ts CHANGED
@@ -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,7 +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";
17
- import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./autorouter-feed.ts";
19
+ import { ROUTED_MODEL_CHANNEL, routedModelAnnouncement } from "./routesense-feed.ts";
18
20
  import {
19
21
  dimAttribute,
20
22
  getThinkingLevelColorizer,
@@ -40,6 +42,7 @@ import {
40
42
  } from "./format.ts";
41
43
  import { buildLoaderMessage } from "./loader-message.ts";
42
44
  import { showMenu } from "./menu.ts";
45
+ import { applyOutputStyle, foldedOutputStyleSection, OUTPUT_STYLE_MESSAGE_TYPE, OUTPUT_STYLE_SECTION, OUTPUT_STYLES, outputStyleSectionPatch } from "./output-style.ts";
43
46
  import { getResponseModelColorizer } from "./nvidia-green.ts";
44
47
  import { registerSetupCommand } from "./setup-command.ts";
45
48
  import { applyMenuResult, buildMenuSections, loadSettings, saveSettings, type ThinkingLevelColor } from "./settings.ts";
@@ -58,6 +61,7 @@ type MessageEndEvent = Extract<ExtensionEvent, { type: "message_end" }>;
58
61
  type ToolExecutionStartEvent = Extract<ExtensionEvent, { type: "tool_execution_start" }>;
59
62
  type UIPromptStartEvent = Extract<ExtensionEvent, { type: "ui_prompt_start" }>;
60
63
  type UIPromptEndEvent = Extract<ExtensionEvent, { type: "ui_prompt_end" }>;
64
+ type ContextWithSystemEvent = Extract<ExtensionEvent, { type: "context_with_system" }>;
61
65
  type ProviderStreamEvent = Extract<ExtensionEvent, { type: "provider_stream_event" }>;
62
66
  type UIPromptKind = UIPromptStartEvent["kind"];
63
67
 
@@ -79,6 +83,7 @@ const TOKEN_RATE_HOLD_MS = 1_500;
79
83
  const TOKEN_RATE_FADE_MS = 250;
80
84
 
81
85
  interface DoneEntryData {
86
+ aborted?: boolean;
82
87
  word: string;
83
88
  elapsedMs: number;
84
89
  tokens?: number;
@@ -221,6 +226,36 @@ export class SessionManager {
221
226
  }
222
227
  };
223
228
 
229
+ /** Unlike the UI handlers, this runs in every mode: the output style shapes the model's prompt. */
230
+ #onBeforeAgentStart = (event: BeforeAgentStartEvent): BeforeAgentStartEventResult | undefined =>
231
+ applyOutputStyle(event.systemPromptOptions?.sections, this.#settings.outputStyle);
232
+
233
+ /**
234
+ * A decorated prompt is a custom message, so Pi does not run before_agent_start for it.
235
+ * Put the missing prompt section on the request. A matching transcript section is left alone.
236
+ */
237
+ #onContextWithSystem = (event: ContextWithSystemEvent): { messages: ContextWithSystemEvent["messages"] } | undefined => {
238
+ const patch = outputStyleSectionPatch(foldedOutputStyleSection(event.messages), this.#settings.outputStyle);
239
+ if (patch === undefined) return undefined;
240
+ const messages = event.messages.slice();
241
+ let index = messages.length;
242
+ for (let i = messages.length - 1; i >= 0; i--) {
243
+ const message = messages[i];
244
+ if (!message) continue;
245
+ if (message.role === "user" || (message.role === "custom" && message.customType === PROMPT_BOX_TYPE)) {
246
+ index = i;
247
+ break;
248
+ }
249
+ }
250
+ messages.splice(index, 0, {
251
+ role: "system",
252
+ content: "",
253
+ sections: { [OUTPUT_STYLE_SECTION]: patch },
254
+ timestamp: Date.now(),
255
+ });
256
+ return { messages };
257
+ };
258
+
224
259
  #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
225
260
  this.#currentCtx = ctx;
226
261
  if (!this.usable(ctx)) return;
@@ -229,6 +264,7 @@ export class SessionManager {
229
264
  else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
230
265
  if (this.#settings.decorations.decorateUserPrompt && this.shouldDecorate(event)) {
231
266
  try {
267
+ const stylePrompt = OUTPUT_STYLES[this.#settings.outputStyle].prompt;
232
268
  await this.#pi.sendMessage<PromptBoxDetails>(
233
269
  {
234
270
  customType: PROMPT_BOX_TYPE,
@@ -248,8 +284,18 @@ export class SessionManager {
248
284
  thinkingLevel: ctx.thinkingLevel,
249
285
  },
250
286
  },
251
- { triggerTurn: true },
287
+ { triggerTurn: stylePrompt === undefined },
252
288
  );
289
+ if (stylePrompt) {
290
+ await this.#pi.sendMessage(
291
+ {
292
+ customType: OUTPUT_STYLE_MESSAGE_TYPE,
293
+ content: stylePrompt.reminder,
294
+ display: false,
295
+ },
296
+ { triggerTurn: true },
297
+ );
298
+ }
253
299
  return { action: "handled" };
254
300
  } catch {
255
301
  ctx.ui.notify("Failed to submit prompt", "error");
@@ -356,7 +402,7 @@ export class SessionManager {
356
402
  this.tick();
357
403
  };
358
404
 
359
- #onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
405
+ #onAgentSettled = async (e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
360
406
  this.#currentCtx = ctx;
361
407
  if (!this.usable(ctx)) return;
362
408
  this.commitServiceTierCall();
@@ -379,8 +425,10 @@ export class SessionManager {
379
425
  resetTokenRateState(this.#state);
380
426
  this.#state.lastMessage = NOT_SENT;
381
427
  if (this.#settings.features.doneMarker && hadPrompt) {
428
+ const aborted = e.aborted === true;
382
429
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
383
- word: this.#settings.features.randomizeDoneMarker ? this.#state.workingText.pastTense : "Worked",
430
+ ...(aborted ? { aborted: true } : {}),
431
+ word: aborted ? "Interrupted" : this.#settings.features.randomizeDoneMarker ? this.#state.workingText.pastTense : "Worked",
384
432
  elapsedMs,
385
433
  tokens: this.#state.confirmTokens,
386
434
  midTurnInputs: this.#state.midTurnInputs,
@@ -401,6 +449,7 @@ export class SessionManager {
401
449
 
402
450
  #renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
403
451
  if (!entry.data) return undefined;
452
+ const aborted = entry.data.aborted === true;
404
453
  const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
405
454
  const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
406
455
  const features = this.#settings.features;
@@ -420,9 +469,9 @@ export class SessionManager {
420
469
  ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
421
470
  : "";
422
471
  const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
423
- const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model);
472
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model, aborted ? "warning" : "dim");
424
473
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
425
- const borderColor = this.#settings.decorations.doneMarkerBorderColor;
474
+ const borderColor = aborted ? "warning" : this.#settings.decorations.doneMarkerBorderColor;
426
475
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
427
476
  if (borderStyle === "none") return new Text(markerContent);
428
477
 
@@ -445,6 +494,8 @@ export class SessionManager {
445
494
 
446
495
  install(): void {
447
496
  this.#pi.on("session_start", this.#onSessionStart);
497
+ this.#pi.on("before_agent_start", this.#onBeforeAgentStart);
498
+ this.#pi.on("context_with_system", this.#onContextWithSystem);
448
499
  this.#pi.on("input", this.#onInput);
449
500
  this.#pi.on("agent_start", this.#onAgentStart);
450
501
  this.#pi.on("message_start", this.#onMessageStart);
@@ -470,7 +521,7 @@ export class SessionManager {
470
521
  this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
471
522
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
472
523
  this.#pi.registerCommand("topping-settings", {
473
- description: "Configure prompt decoration, working-loader features/order, and completion-marker settings.",
524
+ description: "Configure the prompt box, working loader, completion marker, word packs, and output style.",
474
525
  handler: async (_a, ctx) => this.showSettings(ctx),
475
526
  });
476
527
  registerSetupCommand(this.#pi);
@@ -789,6 +840,7 @@ export class SessionManager {
789
840
  this.#userPacks = loadUserWordPacks();
790
841
  this.#allPacks = [...this.#bundledPacks, ...this.#userPacks];
791
842
  const before = this.indicatorFingerprint();
843
+ const styleBefore = this.#settings.outputStyle;
792
844
  const preview = new PreviewRenderer(ctx, this.#allPacks);
793
845
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
794
846
  title: "Pi Topping: Settings",
@@ -818,6 +870,9 @@ export class SessionManager {
818
870
  this.startTimer();
819
871
  this.tick();
820
872
  }
821
- ctx.ui.notify("Pi Topping settings saved", "info");
873
+ const notification = styleBefore === this.#settings.outputStyle
874
+ ? "Pi Topping settings saved"
875
+ : `Pi Topping settings saved · ${OUTPUT_STYLES[this.#settings.outputStyle].label} output style applies from your next message`;
876
+ ctx.ui.notify(notification, "info");
822
877
  }
823
878
  }
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";
@@ -125,6 +126,7 @@ export interface DecoratorSettings {
125
126
  };
126
127
  loaderOrder: LoaderElement[];
127
128
  wordPacks: Record<string, boolean>;
129
+ outputStyle: OutputStyleId;
128
130
  }
129
131
 
130
132
  export const DEFAULT_SETTINGS: DecoratorSettings = {
@@ -132,10 +134,13 @@ export const DEFAULT_SETTINGS: DecoratorSettings = {
132
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 },
133
135
  loaderOrder: [...DEFAULT_LOADER_ORDER],
134
136
  wordPacks: {},
137
+ outputStyle: "default",
135
138
  };
136
139
 
137
140
  /** Menu key carrying the loader element order as a comma-joined list of element ids. */
138
141
  export const LOADER_ORDER_ID = "loaderOrder";
142
+ /** Menu key for the output style cycle row. */
143
+ export const OUTPUT_STYLE_ID = "outputStyle";
139
144
  const LOADER_ELEMENT_LABELS: Record<LoaderElement, string> = {
140
145
  spinner: "Animated spinner",
141
146
  text: "“Working” text",
@@ -163,6 +168,19 @@ export function parseLoaderOrder(value: unknown): LoaderElement[] {
163
168
  }
164
169
 
165
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
+
166
184
  function mergeGroup<T extends Record<string, boolean | string | number>>(defaults: T, parsed: unknown): T {
167
185
  const merged = { ...defaults };
168
186
  if (!isPlainObject(parsed)) return merged;
@@ -313,6 +331,10 @@ export function buildMenuSections(settings: DecoratorSettings, bundledPacks: rea
313
331
  ...buildSection("Word Packs", settings).items.map((item, index) => ({ ...item, spacerBefore: index === 0, disabledUnlessAnyOf: packIds })),
314
332
  ],
315
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
+ },
316
338
  buildSection("Options", settings),
317
339
  ];
318
340
  }
@@ -337,6 +359,8 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
337
359
  if (isWordPackId(packId)) next.wordPacks[packId] = value;
338
360
  }
339
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;
340
364
  return next;
341
365
  }
342
366
 
@@ -350,12 +374,13 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
350
374
  * border color is reset. Below 4, the response-model footer inherits the response-model
351
375
  * setting. A missing, non-integer, or out-of-range meter upper bound resolves
352
376
  * to the default of 80 tps. A legacy `features.simCityWorkingText: true` enables the
353
- * `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.
354
379
  */
355
380
  export function loadSettings(): DecoratorSettings {
356
381
  try {
357
382
  const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
358
- if (!isPlainObject(parsed)) return structuredClone(DEFAULT_SETTINGS);
383
+ if (!isPlainObject(parsed)) return defaultSettings();
359
384
  const wordPacks: Record<string, boolean> = { ...DEFAULT_SETTINGS.wordPacks };
360
385
  if (isPlainObject(parsed.wordPacks)) {
361
386
  for (const [id, enabled] of Object.entries(parsed.wordPacks)) {
@@ -365,7 +390,9 @@ export function loadSettings(): DecoratorSettings {
365
390
  if (isPlainObject(parsed.features) && parsed.features.simCityWorkingText === true && !("simcity" in wordPacks)) {
366
391
  wordPacks.simcity = true;
367
392
  }
368
- 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 };
369
396
  if (isPlainObject(parsed.decorations)) {
370
397
  for (const [key, value] of Object.entries(parsed.decorations)) {
371
398
  if (!Object.hasOwn(DEFAULT_SETTINGS.decorations, key) || typeof value !== "string") continue;
@@ -394,7 +421,7 @@ export function loadSettings(): DecoratorSettings {
394
421
  }
395
422
  }
396
423
  return settings;
397
- } catch { return structuredClone(DEFAULT_SETTINGS); }
424
+ } catch { return defaultSettings(); }
398
425
  }
399
426
 
400
427
  export function atomicWriteFile(path: string, contents: string): void {