@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 +29 -5
- package/package.json +4 -4
- package/src/autorouter-feed.ts +49 -0
- package/src/loader-message.ts +1 -1
- package/src/output-style.ts +172 -0
- package/src/preview.ts +8 -1
- package/src/prompt-decorator.ts +6 -4
- package/src/served-model.ts +19 -0
- package/src/service-tier.ts +86 -0
- package/src/session.ts +147 -26
- package/src/settings.ts +34 -5
- package/src/util.ts +4 -0
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
|

|
|
8
8
|
|
|
9
|
-
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
|
|
9
|
+
**“Working” Loader** — animated spinner (with color choice), randomized activity word, optional word packs, shimmer, token activity monitor with an adjustable full-scale rate, elapsed timer, output token display, live output-token rate, and the response model when it meaningfully differs from the selected model — all arrangeable left to right. For raw Responses-API stream events, it can also include Ramp Router's gateway backend as `<model> via <backend>`, or just `via <backend>` when the served model restates the selection (requires Pi ≥0.99.0). Under a Pi virtual model such as pi-topping-autorouter's `litellm-auto/auto`, the response model is the routed physical model. During a blocking extension prompt, the loader switches to a stable dim waiting line with a pulsing indicator, then resumes afterward.
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
-
**Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. It can append the response model when it differs from the selected model, with its own color option. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event.
|
|
13
|
+
**Completion Marker** — end-of-turn marker with icon, randomized verb (or `Worked`; matching past tense when Randomize "Worked" text is on), token consumption display, and optional marker-style, border-style, and border-color decorations spanning the full terminal width. It can append the response model when it differs from the selected model, with its own color option. It can also append per-call service-tier counts (e.g. `flex tier ×3`), but only for calls whose provider reports a tier; this requires Pi ≥0.99.0. The default `elite` style trails the summary with tapering dashes; `bookend` fills the remaining width to a closing corner glyph. Hooks into Pi's `agent_settled` event. 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
|

|
|
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/
|
|
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.
|
|
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,
|
|
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.
|
|
34
|
-
"@earendil-works/pi-tui": "1.
|
|
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
|
+
}
|
package/src/loader-message.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { buildWorkingMessage, DEFAULT_WORKING_WORD, dimAttribute, fadeThemeColor
|
|
|
3
3
|
import { getResponseModelColorizer } from "./nvidia-green.ts";
|
|
4
4
|
import type { DecoratorSettings } from "./settings.ts";
|
|
5
5
|
|
|
6
|
-
type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate"
|
|
6
|
+
type Features = Pick<DecoratorSettings["features"], "substituteDefaultMessage" | "elapsedTime" | "outputTokens" | "tokenRate">;
|
|
7
7
|
type Decorations = Pick<DecoratorSettings["decorations"], "shimmer" | "shimmerInverted" | "shimmerDirection" | "shimmerSpeed" | "tokenActivityMonitor" | "meterColor" | "meterDimmed" | "tokenRateColor" | "tokenRateDimmed" | "responseModelColor" | "responseModelDimmed" | "spinnerColor">;
|
|
8
8
|
interface LoaderParts {
|
|
9
9
|
spinnerAtMs?: number;
|
|
@@ -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 {
|
|
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;
|
package/src/prompt-decorator.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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 (
|
|
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?:
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|