@underactive/pi-topping 0.10.0 → 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
@@ -10,10 +10,12 @@ We garnish our pies. It seemed rude not to extend Pi the same courtesy. This is
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
@@ -153,6 +172,8 @@ The response model is captured from assistant responses and raw Responses-API st
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.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.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,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,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
 
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,
@@ -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, OUTPUT_STYLES } 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";
@@ -79,6 +82,7 @@ const TOKEN_RATE_HOLD_MS = 1_500;
79
82
  const TOKEN_RATE_FADE_MS = 250;
80
83
 
81
84
  interface DoneEntryData {
85
+ aborted?: boolean;
82
86
  word: string;
83
87
  elapsedMs: number;
84
88
  tokens?: number;
@@ -221,13 +225,21 @@ export class SessionManager {
221
225
  }
222
226
  };
223
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
+
224
232
  #onInput = async (event: InputEvent, ctx: ExtensionContext): Promise<InputEventResult | void> => {
225
233
  this.#currentCtx = ctx;
226
234
  if (!this.usable(ctx)) return;
227
235
 
228
236
  if (!event.streamingBehavior && !this.#state.busy) this.resetTurn(Date.now());
229
237
  else if (event.streamingBehavior && event.source !== "extension") this.#state.midTurnInputs++;
230
- 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
+ ) {
231
243
  try {
232
244
  await this.#pi.sendMessage<PromptBoxDetails>(
233
245
  {
@@ -356,7 +368,7 @@ export class SessionManager {
356
368
  this.tick();
357
369
  };
358
370
 
359
- #onAgentSettled = async (_e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
371
+ #onAgentSettled = async (e: AgentSettledEvent, ctx: ExtensionContext): Promise<void> => {
360
372
  this.#currentCtx = ctx;
361
373
  if (!this.usable(ctx)) return;
362
374
  this.commitServiceTierCall();
@@ -379,8 +391,10 @@ export class SessionManager {
379
391
  resetTokenRateState(this.#state);
380
392
  this.#state.lastMessage = NOT_SENT;
381
393
  if (this.#settings.features.doneMarker && hadPrompt) {
394
+ const aborted = e.aborted === true;
382
395
  this.#pi.appendEntry<DoneEntryData>(DONE_ENTRY_TYPE, {
383
- 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",
384
398
  elapsedMs,
385
399
  tokens: this.#state.confirmTokens,
386
400
  midTurnInputs: this.#state.midTurnInputs,
@@ -401,6 +415,7 @@ export class SessionManager {
401
415
 
402
416
  #renderDoneEntry(entry: CustomEntry<DoneEntryData>, theme: Theme): Component | undefined {
403
417
  if (!entry.data) return undefined;
418
+ const aborted = entry.data.aborted === true;
404
419
  const word = typeof entry.data.word === "string" ? stripControlChars(entry.data.word) : "Worked";
405
420
  const elapsedMs = typeof entry.data.elapsedMs === "number" && Number.isFinite(entry.data.elapsedMs) ? entry.data.elapsedMs : 0;
406
421
  const features = this.#settings.features;
@@ -420,9 +435,9 @@ export class SessionManager {
420
435
  ? getThinkingLevelColorizer(theme, this.#settings.decorations.doneMarkerModelColor, thinkingLevel)(rawModel)
421
436
  : "";
422
437
  const model = modelColored && this.#settings.decorations.doneMarkerModelDimmed ? dimAttribute(modelColored) : modelColored;
423
- const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model);
438
+ const markerContent = buildCompletionMarkerContent(theme, icon, word, formatElapsed(elapsedMs), details, model, aborted ? "warning" : "dim");
424
439
  const borderStyle = this.#settings.decorations.doneMarkerBorderStyle;
425
- const borderColor = this.#settings.decorations.doneMarkerBorderColor;
440
+ const borderColor = aborted ? "warning" : this.#settings.decorations.doneMarkerBorderColor;
426
441
  const markerStyle = this.#settings.decorations.doneMarkerStyle;
427
442
  if (borderStyle === "none") return new Text(markerContent);
428
443
 
@@ -445,6 +460,7 @@ export class SessionManager {
445
460
 
446
461
  install(): void {
447
462
  this.#pi.on("session_start", this.#onSessionStart);
463
+ this.#pi.on("before_agent_start", this.#onBeforeAgentStart);
448
464
  this.#pi.on("input", this.#onInput);
449
465
  this.#pi.on("agent_start", this.#onAgentStart);
450
466
  this.#pi.on("message_start", this.#onMessageStart);
@@ -470,7 +486,7 @@ export class SessionManager {
470
486
  this.#pi.registerEntryRenderer<MissingToppingsEntryData>(SETUP_ENTRY_TYPE, (entry, _o, theme) => renderMissingToppingsEntry(entry, theme));
471
487
  this.#pi.registerMessageRenderer<PromptBoxDetails>(PROMPT_BOX_TYPE, promptBoxRenderer);
472
488
  this.#pi.registerCommand("topping-settings", {
473
- 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.",
474
490
  handler: async (_a, ctx) => this.showSettings(ctx),
475
491
  });
476
492
  registerSetupCommand(this.#pi);
@@ -789,6 +805,7 @@ export class SessionManager {
789
805
  this.#userPacks = loadUserWordPacks();
790
806
  this.#allPacks = [...this.#bundledPacks, ...this.#userPacks];
791
807
  const before = this.indicatorFingerprint();
808
+ const styleBefore = this.#settings.outputStyle;
792
809
  const preview = new PreviewRenderer(ctx, this.#allPacks);
793
810
  const result = await showMenu<Record<string, boolean | string>>(ctx, {
794
811
  title: "Pi Topping: Settings",
@@ -818,6 +835,9 @@ export class SessionManager {
818
835
  this.startTimer();
819
836
  this.tick();
820
837
  }
821
- 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");
822
842
  }
823
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";
@@ -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 {