@underactive/pi-topping 0.11.1 → 0.13.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/src/settings.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
- import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
4
- import { DEFAULT_METER_UPPER_BOUND_TPS, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN } from "./activity-meter.ts";
2
+ import { join } from "node:path";
3
+ import { createSettingsStore, type SettingsWriteResult } from "@underactive/pi-topping-primitives/settings";
4
+ import { DEFAULT_METER_UPPER_BOUND_TPS, METER_UPPER_BOUND_TPS_MAX, METER_UPPER_BOUND_TPS_MIN } from "@underactive/pi-topping-primitives/activity";
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";
7
- import type { MenuNumberEntry, MenuSection } from "./menu.ts";
6
+ import { isOutputStyleId, OUTPUT_STYLE_IDS, OUTPUT_STYLES, type OutputStyleId } from "./output-style.ts";
7
+ import type { MenuItem, MenuSection, MenuValue } from "@underactive/pi-topping-primitives/menu";
8
8
  import { isWordPackEnabled, isWordPackId, WORD_PACK_MENU_PREFIX, type WordPack } from "./word-packs.ts";
9
9
  import { isPlainObject } from "./util.ts";
10
10
 
@@ -31,7 +31,7 @@ export const DONE_MARKER_BORDER_STYLE_VALUES = [...BORDER_STYLE_VALUES, "none"]
31
31
  export type DoneMarkerBorderStyle = (typeof DONE_MARKER_BORDER_STYLE_VALUES)[number];
32
32
  export const DONE_MARKER_STYLE_VALUES = ["elite", "bookend"] as const;
33
33
  export type DoneMarkerStyle = (typeof DONE_MARKER_STYLE_VALUES)[number];
34
- export const METER_UPPER_BOUND_TPS_PRESETS = ["10", "20", "30", "40", "50", "60", "80", "100", "150", "200", "300", "500", "1000"] as const;
34
+ export const METER_UPPER_BOUND_TPS_PRESETS = [10, 20, 30, 40, 50, 60, 80, 100, 150, 200, 300, 500, 1000] as const;
35
35
 
36
36
  export function isBorderStyle(value: unknown): value is BorderStyle {
37
37
  return typeof value === "string" && BORDER_STYLE_VALUES.some(style => style === value);
@@ -169,16 +169,8 @@ export function parseLoaderOrder(value: unknown): LoaderElement[] {
169
169
 
170
170
  export function settingsPath(): string { return join(getAgentDir(), "pi-topping", "settings.json"); }
171
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
172
  function defaultSettings(): DecoratorSettings {
181
- return { ...structuredClone(DEFAULT_SETTINGS), outputStyle: legacyOutputStyle() };
173
+ return structuredClone(DEFAULT_SETTINGS);
182
174
  }
183
175
 
184
176
  function mergeGroup<T extends Record<string, boolean | string | number>>(defaults: T, parsed: unknown): T {
@@ -206,8 +198,9 @@ type DecorationSettings = DecoratorSettings["decorations"];
206
198
  type FeatureSettings = DecoratorSettings["features"];
207
199
  type DecorationBooleanKey = { [Key in keyof DecorationSettings]: DecorationSettings[Key] extends boolean ? Key : never }[keyof DecorationSettings];
208
200
  type MenuEntryBase = { id: string; label: string; section: MenuSectionName };
209
- type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleValueLabels?: Readonly<Record<string, string>>; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string; numberEntry?: MenuNumberEntry };
210
- type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleValueLabels?: never; cycleEnabledBy?: never; cycleDisabledValue?: never; numberEntry?: never };
201
+ type NumberMenuEntry = { min: number; max: number; unit?: string; presets: readonly number[] };
202
+ type DecorationMenuEntry = MenuEntryBase & { group: "decorations"; key: keyof DecorationSettings; cycleValues?: readonly string[]; cycleValueLabels?: Readonly<Record<string, string>>; cycleEnabledBy?: DecorationBooleanKey; cycleDisabledValue?: string; number?: NumberMenuEntry };
203
+ type FeatureMenuEntry = MenuEntryBase & { group: "features"; key: keyof FeatureSettings; cycleValues?: never; cycleValueLabels?: never; cycleEnabledBy?: never; cycleDisabledValue?: never; number?: never };
211
204
  type MenuEntry = DecorationMenuEntry | FeatureMenuEntry;
212
205
  const THINKING_LEVEL_CYCLE_LABELS = { "thinking-level": "thinkingLevel" } as const;
213
206
  const DIRECTION_CYCLE_LABELS = { ltr: "Left to Right", rtl: "Right to Left" } as const;
@@ -231,7 +224,7 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
231
224
  { id: "meterColor", label: "Token activity monitor color", section: "“Working” Loader", group: "decorations", key: "meterColor", cycleValues: THINKING_LEVEL_COLOR_VALUES, cycleValueLabels: THINKING_LEVEL_CYCLE_LABELS, cycleEnabledBy: "meterColorEnabled", cycleDisabledValue: "accent" },
232
225
  { id: "meterDirection", label: "Token activity monitor direction", section: "“Working” Loader", group: "decorations", key: "meterDirection", cycleValues: ["ltr", "rtl"], cycleValueLabels: DIRECTION_CYCLE_LABELS, cycleEnabledBy: "meterDirectionEnabled", cycleDisabledValue: "rtl" },
233
226
  { id: "meterDimmed", label: "Token activity monitor dimmed", section: "“Working” Loader", group: "decorations", key: "meterDimmed" },
234
- { id: "meterUpperBoundTps", label: "Token activity monitor upper bound", section: "“Working” Loader", group: "decorations", key: "meterUpperBoundTps", cycleValues: METER_UPPER_BOUND_TPS_PRESETS, numberEntry: { min: METER_UPPER_BOUND_TPS_MIN, max: METER_UPPER_BOUND_TPS_MAX, unit: "tps" } },
227
+ { id: "meterUpperBoundTps", label: "Token activity monitor upper bound", section: "“Working” Loader", group: "decorations", key: "meterUpperBoundTps", number: { min: METER_UPPER_BOUND_TPS_MIN, max: METER_UPPER_BOUND_TPS_MAX, unit: "tps", presets: METER_UPPER_BOUND_TPS_PRESETS } },
235
228
  { id: "elapsedTime", label: "Elapsed time since prompt", section: "“Working” Loader", group: "features", key: "elapsedTime" },
236
229
  { id: "outputTokens", label: "Show output tokens", section: "“Working” Loader", group: "features", key: "outputTokens" },
237
230
  // id differs from key: the Elements Order row already owns "tokenRate" in the menu's shared value namespace.
@@ -259,12 +252,26 @@ export const MENU_ENTRIES: readonly MenuEntry[] = [
259
252
  { id: "includeDefaultWorkingText", label: "Include default/random “Working” text", section: "Word Packs", group: "features", key: "includeDefaultWorkingText" },
260
253
  ];
261
254
 
262
- function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuSection["items"][number] {
255
+ function menuItem(entry: MenuEntry, settings: DecoratorSettings): MenuItem {
263
256
  const value = entry.group === "decorations" ? settings.decorations[entry.key] : settings.features[entry.key];
264
- const cycleEnabled = entry.group === "decorations" && entry.cycleEnabledBy
265
- ? settings.decorations[entry.cycleEnabledBy]
266
- : undefined;
267
- return { id: entry.id, label: entry.label, cycleValues: entry.cycleValues, cycleValueLabels: entry.cycleValueLabels, cycleEnabledBy: entry.cycleEnabledBy, cycleDisabledValue: entry.cycleDisabledValue, cycleEnabled, numberEntry: entry.numberEntry, value: typeof value === "number" ? String(value) : value };
257
+ if (entry.group === "decorations" && entry.number) {
258
+ return { kind: "number", id: entry.id, label: entry.label, value: typeof value === "number" ? value : DEFAULT_METER_UPPER_BOUND_TPS, ...entry.number };
259
+ }
260
+ if (entry.group === "decorations" && entry.cycleValues) {
261
+ const gate = entry.cycleEnabledBy
262
+ ? { id: entry.cycleEnabledBy, enabled: settings.decorations[entry.cycleEnabledBy], ...(entry.cycleDisabledValue === undefined ? {} : { disabledValue: entry.cycleDisabledValue }) }
263
+ : undefined;
264
+ return {
265
+ kind: "cycle",
266
+ id: entry.id,
267
+ label: entry.label,
268
+ value: String(value),
269
+ values: entry.cycleValues,
270
+ ...(entry.cycleValueLabels ? { labels: entry.cycleValueLabels } : {}),
271
+ ...(gate ? { gate } : {}),
272
+ };
273
+ }
274
+ return { kind: "toggle", id: entry.id, label: entry.label, value: value === true };
268
275
  }
269
276
 
270
277
  function isDecorationBooleanKey(key: keyof DecorationSettings): key is DecorationBooleanKey {
@@ -317,12 +324,12 @@ function buildSection(title: MenuSectionName, settings: DecoratorSettings): Menu
317
324
 
318
325
  export function buildMenuSections(settings: DecoratorSettings, bundledPacks: readonly WordPack[], userPacks: readonly WordPack[] = []): MenuSection[] {
319
326
  const packs = [...bundledPacks, ...userPacks];
320
- const packItems = packs.map((pack) => ({ id: `${WORD_PACK_MENU_PREFIX}${pack.id}`, label: pack.name, value: isWordPackEnabled(pack.id, settings.wordPacks) }));
327
+ const packItems: MenuItem[] = packs.map((pack) => ({ kind: "toggle", id: `${WORD_PACK_MENU_PREFIX}${pack.id}`, label: pack.name, value: isWordPackEnabled(pack.id, settings.wordPacks) }));
321
328
  const packIds = packItems.map(item => item.id);
322
329
  return [
323
330
  buildSection("User Prompt", settings),
324
331
  buildSection("“Working” Loader", settings),
325
- { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ id, label: LOADER_ELEMENT_LABELS[id], value: false, reorderGroup: LOADER_ORDER_ID })) },
332
+ { title: "Elements Order", items: parseLoaderOrder(settings.loaderOrder).map(id => ({ kind: "reorder" as const, id, label: LOADER_ELEMENT_LABELS[id], group: LOADER_ORDER_ID })) },
326
333
  buildSection("Completion Marker", settings),
327
334
  {
328
335
  title: "Word Packs",
@@ -333,18 +340,21 @@ export function buildMenuSections(settings: DecoratorSettings, bundledPacks: rea
333
340
  },
334
341
  {
335
342
  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])) }],
343
+ items: [{ kind: "cycle", id: OUTPUT_STYLE_ID, label: "Style", value: settings.outputStyle, values: OUTPUT_STYLE_IDS, labels: Object.fromEntries(OUTPUT_STYLE_IDS.map(id => [id, OUTPUT_STYLES[id].label])) }],
337
344
  },
338
345
  buildSection("Options", settings),
339
346
  ];
340
347
  }
341
348
 
342
- export function applyMenuResult(settings: DecoratorSettings, values: Record<string, boolean | string>): DecoratorSettings {
349
+ export function applyMenuResult(settings: DecoratorSettings, values: Record<string, MenuValue>): DecoratorSettings {
343
350
  const next = structuredClone(settings);
344
351
  for (const entry of MENU_ENTRIES) {
345
352
  const value = values[entry.id];
346
353
  if (value === undefined) continue;
347
- if (entry.group === "decorations" && entry.cycleValues && typeof value === "string" && (entry.cycleValues.includes(value) || entry.numberEntry !== undefined)) {
354
+ if (entry.group === "decorations" && entry.number && (typeof value === "number" || typeof value === "string")) {
355
+ // The shared menu publishes a number; a decimal string from an older caller still parses.
356
+ setDecorationCycleValue(next.decorations, entry.key, String(value));
357
+ } else if (entry.group === "decorations" && entry.cycleValues && typeof value === "string" && entry.cycleValues.includes(value)) {
348
358
  setDecorationCycleValue(next.decorations, entry.key, value);
349
359
  if (entry.cycleEnabledBy) next.decorations[entry.cycleEnabledBy] = values[entry.cycleEnabledBy] !== false;
350
360
  } else if (entry.group === "decorations" && typeof value === "boolean" && isDecorationBooleanKey(entry.key)) {
@@ -375,67 +385,63 @@ export function applyMenuResult(settings: DecoratorSettings, values: Record<stri
375
385
  * setting. A missing, non-integer, or out-of-range meter upper bound resolves
376
386
  * to the default of 80 tps. A legacy `features.simCityWorkingText: true` enables the
377
387
  * `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.
388
+ * falls back to `default`, without a schema-version gate.
379
389
  */
380
- export function loadSettings(): DecoratorSettings {
381
- try {
382
- const parsed = JSON.parse(readFileSync(settingsPath(), "utf8"));
383
- if (!isPlainObject(parsed)) return defaultSettings();
384
- const wordPacks: Record<string, boolean> = { ...DEFAULT_SETTINGS.wordPacks };
385
- if (isPlainObject(parsed.wordPacks)) {
386
- for (const [id, enabled] of Object.entries(parsed.wordPacks)) {
387
- if (isWordPackId(id) && typeof enabled === "boolean") wordPacks[id] = enabled;
388
- }
390
+ function sanitizeSettings(parsed: unknown): DecoratorSettings {
391
+ if (!isPlainObject(parsed)) return defaultSettings();
392
+ const wordPacks: Record<string, boolean> = { ...DEFAULT_SETTINGS.wordPacks };
393
+ if (isPlainObject(parsed.wordPacks)) {
394
+ for (const [id, enabled] of Object.entries(parsed.wordPacks)) {
395
+ if (isWordPackId(id) && typeof enabled === "boolean") wordPacks[id] = enabled;
389
396
  }
390
- if (isPlainObject(parsed.features) && parsed.features.simCityWorkingText === true && !("simcity" in wordPacks)) {
391
- wordPacks.simcity = true;
392
- }
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 };
396
- if (isPlainObject(parsed.decorations)) {
397
- for (const [key, value] of Object.entries(parsed.decorations)) {
398
- if (!Object.hasOwn(DEFAULT_SETTINGS.decorations, key) || typeof value !== "string") continue;
399
- const k = key as keyof DecorationSettings;
400
- if (isDecorationBooleanKey(k)) continue;
401
- setDecorationCycleValue(settings.decorations, k,
402
- value === "default" && (k === "spinnerColor" || k === "doneMarkerBorderColor") ? "thinking-level" : value);
403
- }
404
- const bound = parseMeterUpperBoundTps(parsed.decorations.meterUpperBoundTps);
405
- if (bound !== undefined) settings.decorations.meterUpperBoundTps = bound;
406
- }
407
- const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
408
- if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) {
409
- settings.decorations.spinnerColor = "thinking-level";
410
- if (settings.decorations.borderColor === "borderAccent") settings.decorations.borderColor = "thinking-level";
411
- }
412
- if (schemaVersion < DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION) {
413
- settings.decorations.doneMarkerBorderColor = "thinking-level";
414
- }
415
- if (schemaVersion < RESPONSE_MODEL_FOOTER_MIGRATION_VERSION) {
416
- settings.features.responseModelFooter = settings.features.responseModel;
397
+ }
398
+ if (isPlainObject(parsed.features) && parsed.features.simCityWorkingText === true && !("simcity" in wordPacks)) {
399
+ wordPacks.simcity = true;
400
+ }
401
+ const storedOutputStyle = parsed.outputStyle;
402
+ const outputStyle = isOutputStyleId(storedOutputStyle) ? storedOutputStyle : DEFAULT_SETTINGS.outputStyle;
403
+ const settings = { decorations: mergeGroup(DEFAULT_SETTINGS.decorations, parsed.decorations), features: mergeGroup(DEFAULT_SETTINGS.features, parsed.features), loaderOrder: parseLoaderOrder(parsed.loaderOrder), wordPacks, outputStyle };
404
+ if (isPlainObject(parsed.decorations)) {
405
+ for (const [key, value] of Object.entries(parsed.decorations)) {
406
+ if (!Object.hasOwn(DEFAULT_SETTINGS.decorations, key) || typeof value !== "string") continue;
407
+ const k = key as keyof DecorationSettings;
408
+ if (isDecorationBooleanKey(k)) continue;
409
+ setDecorationCycleValue(settings.decorations, k,
410
+ value === "default" && (k === "spinnerColor" || k === "doneMarkerBorderColor") ? "thinking-level" : value);
417
411
  }
418
- for (const entry of MENU_ENTRIES) {
419
- if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
420
- setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
421
- }
412
+ const bound = parseMeterUpperBoundTps(parsed.decorations.meterUpperBoundTps);
413
+ if (bound !== undefined) settings.decorations.meterUpperBoundTps = bound;
414
+ }
415
+ const schemaVersion = typeof parsed.schemaVersion === "number" ? parsed.schemaVersion : 1;
416
+ if (schemaVersion < THINKING_LEVEL_COLOR_MIGRATION_VERSION) {
417
+ settings.decorations.spinnerColor = "thinking-level";
418
+ if (settings.decorations.borderColor === "borderAccent") settings.decorations.borderColor = "thinking-level";
419
+ }
420
+ if (schemaVersion < DONE_MARKER_BORDER_COLOR_MIGRATION_VERSION) {
421
+ settings.decorations.doneMarkerBorderColor = "thinking-level";
422
+ }
423
+ if (schemaVersion < RESPONSE_MODEL_FOOTER_MIGRATION_VERSION) {
424
+ settings.features.responseModelFooter = settings.features.responseModel;
425
+ }
426
+ for (const entry of MENU_ENTRIES) {
427
+ if (entry.group === "decorations" && entry.cycleEnabledBy && entry.cycleDisabledValue !== undefined && !settings.decorations[entry.cycleEnabledBy]) {
428
+ setDecorationCycleValue(settings.decorations, entry.key, entry.cycleDisabledValue);
422
429
  }
423
- return settings;
424
- } catch { return defaultSettings(); }
430
+ }
431
+ return settings;
425
432
  }
426
433
 
427
- export function atomicWriteFile(path: string, contents: string): void {
428
- mkdirSync(dirname(path), { recursive: true });
429
- const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
430
- try {
431
- writeFileSync(tmpPath, contents, { flag: "wx" });
432
- renameSync(tmpPath, path);
433
- } catch (err) {
434
- try { unlinkSync(tmpPath); } catch { /* tmp file was never created */ }
435
- throw err;
436
- }
434
+ const settingsStore = createSettingsStore<DecoratorSettings>({
435
+ path: settingsPath,
436
+ codec: { sanitize: sanitizeSettings, version: { key: "schemaVersion", current: SETTINGS_SCHEMA_VERSION } },
437
+ });
438
+
439
+ /** A missing, unreadable or malformed file reads as the defaults. Never throws. */
440
+ export function loadSettings(): DecoratorSettings {
441
+ return settingsStore.read();
437
442
  }
438
443
 
439
- export function saveSettings(settings: DecoratorSettings): void {
440
- atomicWriteFile(settingsPath(), `${JSON.stringify({ schemaVersion: SETTINGS_SCHEMA_VERSION, ...settings }, null, 2)}\n`);
444
+ /** Atomic write with `schemaVersion` first. Returns the store result so callers can report a failure. */
445
+ export function saveSettings(settings: DecoratorSettings): SettingsWriteResult<DecoratorSettings> {
446
+ return settingsStore.write(settings);
441
447
  }
@@ -1,7 +1,7 @@
1
1
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { join } from "node:path";
3
3
  import { readFileSync } from "node:fs";
4
- import { atomicWriteFile } from "./settings.ts";
4
+ import { atomicWriteFile } from "@underactive/pi-topping-primitives/settings";
5
5
  import { isPlainObject } from "./util.ts";
6
6
 
7
7
  const setupCheckPath = (): string => join(getAgentDir(), "pi-topping", "setup-check.json");
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
2
  import { hyperlink } from "@earendil-works/pi-tui";
3
- import { type MenuValue, showMenu } from "./menu.ts";
3
+ import { type MenuSection, type MenuValue, showMenu } from "@underactive/pi-topping-primitives/menu";
4
4
  import { type PiInstallResult, spawnPiInstall, spawnPiRemove } from "./pi-installer.ts";
5
5
  import { setSetupCheckDisabled } from "./setup-check.ts";
6
6
  import { detectToppings, type ToppingStatus } from "./toppings.ts";
@@ -132,11 +132,12 @@ export function registerSetupCommand(
132
132
  const rows = new Map<string, SetupRow>();
133
133
  for (const status of missing) rows.set(`install:${status.topping.pkg}`, { status, action: "install" });
134
134
  for (const status of removable) rows.set(`remove:${status.topping.pkg}`, { status, action: "remove" });
135
- const sections = [];
135
+ const sections: MenuSection[] = [];
136
136
  if (missing.length > 0) {
137
137
  sections.push({
138
138
  title: "Missing Toppings",
139
139
  items: missing.map(({ topping, onDisk }) => ({
140
+ kind: "toggle" as const,
140
141
  id: `install:${topping.pkg}`,
141
142
  label: `${topping.pkg}${onDisk ? " (on disk, not active)" : ""}`,
142
143
  value: true,
@@ -147,6 +148,7 @@ export function registerSetupCommand(
147
148
  sections.push({
148
149
  title: "Installed Toppings",
149
150
  items: removable.map(({ topping, active }) => ({
151
+ kind: "toggle" as const,
150
152
  id: `remove:${topping.pkg}`,
151
153
  label: `${topping.pkg}${active ? " (active)" : " (installed, not active)"}`,
152
154
  value: false,
@@ -158,8 +160,11 @@ export function registerSetupCommand(
158
160
  title: "Pi Topping: Setup",
159
161
  sections,
160
162
  hints: ["↑↓ move", "PgUp/PgDn page", "␣ toggle", "⏎ apply", "esc cancel"],
161
- previewTitle: "Description",
162
- preview: (values, _elapsedMs, activeItemId) => ({ lines: renderDescription(rows, activeItemId, values) }),
163
+ preview: {
164
+ title: "Description",
165
+ // The shared menu adds no margin; keep the one-space indent the local menu drew.
166
+ render: (values, { activeItemId }) => ({ lines: renderDescription(rows, activeItemId, values).map((line) => ` ${line}`) }),
167
+ },
163
168
  });
164
169
  if (!result.applied) {
165
170
  ctx.ui.notify("Pi Topping setup cancelled.", "info");
package/src/util.ts CHANGED
@@ -3,14 +3,15 @@ export function isPlainObject(value: unknown): value is Record<string, unknown>
3
3
  return typeof value === "object" && value !== null && !Array.isArray(value);
4
4
  }
5
5
 
6
- /** Strip control and formatting characters from untrusted single-line text. */
7
- export function stripControlChars(text: string): string {
8
- return text.replace(/[\p{Cc}\p{Cf}]/gu, "");
9
- }
6
+ /** Strip control and formatting characters from untrusted single-line text; `sanitizedText` also trims and reads a non-string as empty. */
7
+ export { sanitizedText, stripControlChars } from "@underactive/pi-topping-primitives/text";
10
8
 
11
- export function sanitizedText(value: unknown): string {
12
- return typeof value === "string" ? stripControlChars(value).trim() : "";
13
- }
9
+ /**
10
+ * True when `version` is a release older than `minimum`, both read as `major.minor.patch`.
11
+ * A value that does not start with three numbers cannot be compared and counts as new enough;
12
+ * a pre-release suffix is ignored.
13
+ */
14
+ export { hostBelow } from "@underactive/pi-topping-primitives/process";
14
15
 
15
16
  /** True when a reported response model appears to be a decorated form of the selected model. */
16
17
  export function modelsResemble(selectedModel?: string, responseModel?: string): boolean {
@@ -20,3 +21,24 @@ export function modelsResemble(selectedModel?: string, responseModel?: string):
20
21
  const normalizedResponse = responseModel.toLowerCase();
21
22
  return selectedTokens.length > 0 && selectedTokens.every((token) => normalizedResponse.includes(token));
22
23
  }
24
+
25
+ /** Lowercase letters and digits only, so names that differ just in case and separators compare equal. */
26
+ function spelling(text: string): string {
27
+ return text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "");
28
+ }
29
+
30
+ /**
31
+ * True when a reported response model is the selected model's id, or its display name with or
32
+ * without trailing parenthesized notes, written with other case and separators: a model named
33
+ * `Qwen3.8 Flash (Local)` matches `qwen3.8-flash`. Every digit counts, so `qwen3-flash` does not,
34
+ * and nothing else may differ, so an id with a suffix such as `256k` does not match a response without it.
35
+ */
36
+ export function modelsSpelledAlike(selected: { id: string; name?: string }, responseModel?: string): boolean {
37
+ if (!responseModel) return false;
38
+ const lastSegment = (model: string): string => model.split(/[\\/]/).at(-1) ?? "";
39
+ const response = spelling(lastSegment(responseModel));
40
+ if (!response) return false;
41
+ const name = selected.name ?? "";
42
+ const candidates = [lastSegment(selected.id), name, name.replace(/(?:\s*\([^()]*\))+\s*$/, "")];
43
+ return candidates.some((candidate) => spelling(candidate) === response);
44
+ }
@@ -1,147 +0,0 @@
1
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
- import { dimAttribute } from "./format.ts";
3
-
4
- export const ActivityMeterLevel = {
5
- IDLE: 0,
6
- LOW: 1,
7
- MEDIUM: 2,
8
- HIGH: 3,
9
- PEAK_1: 4,
10
- PEAK_2: 5,
11
- PEAK_3: 6,
12
- FULL: 7,
13
- } as const;
14
-
15
- export type ActivityMeterLevel = (typeof ActivityMeterLevel)[keyof typeof ActivityMeterLevel];
16
-
17
- const EMA_ALPHA = 0.4;
18
- export const DEFAULT_METER_UPPER_BOUND_TPS = 80;
19
- export const METER_UPPER_BOUND_TPS_MIN = 10;
20
- export const METER_UPPER_BOUND_TPS_MAX = 1000;
21
- const BRAILLE: Record<ActivityMeterLevel, string> = {
22
- [ActivityMeterLevel.IDLE]: "⢀",
23
- [ActivityMeterLevel.LOW]: "⣀",
24
- [ActivityMeterLevel.MEDIUM]: "⣠",
25
- [ActivityMeterLevel.HIGH]: "⣤",
26
- [ActivityMeterLevel.PEAK_1]: "⣴",
27
- [ActivityMeterLevel.PEAK_2]: "⣶",
28
- [ActivityMeterLevel.PEAK_3]: "⣾",
29
- [ActivityMeterLevel.FULL]: "⣿",
30
- };
31
- const WIDTH = 8;
32
- const LEGACY_FULL_SCALE_TPS = 40;
33
- // Levels below full retain the original 40 tps scale's proportions.
34
- const RATE_THRESHOLDS = [0, 5, 10, 15, 22, 30] as const;
35
- type CellColorizer = (level: ActivityMeterLevel, char: string) => string;
36
-
37
- /**
38
- * Convert an estimated output-token rate to a display level. Rates at or above `upperBoundTps`
39
- * render as full; the lower levels scale in proportion to the bound.
40
- */
41
- export function rateToLevel(tokensPerSecond: number, upperBoundTps: number = DEFAULT_METER_UPPER_BOUND_TPS): ActivityMeterLevel {
42
- if (tokensPerSecond >= upperBoundTps) return ActivityMeterLevel.FULL;
43
- for (let i = RATE_THRESHOLDS.length - 1; i >= 0; i--) {
44
- if (tokensPerSecond * LEGACY_FULL_SCALE_TPS > RATE_THRESHOLDS[i]! * upperBoundTps) return (i + 1) as ActivityMeterLevel;
45
- }
46
- return ActivityMeterLevel.IDLE;
47
- }
48
-
49
- /** EMA-smoothed rate tracker for a cumulative output-token estimate. */
50
- export class TokRateTracker {
51
- #lastTotal = 0;
52
- #lastTime = 0;
53
- #rate = 0;
54
- #hasSample = false;
55
- #pendingTokens = 0;
56
-
57
- /** Latest EMA-smoothed output-token rate. */
58
- get tokenRate(): number {
59
- return this.#rate;
60
- }
61
-
62
- sample(totalTokens: number, now: number): number {
63
- if (!this.#hasSample) {
64
- this.#lastTotal = totalTokens;
65
- this.#hasSample = true;
66
- this.#lastTime = now;
67
- return this.#rate;
68
- }
69
-
70
- const elapsedSeconds = (now - this.#lastTime) / 1_000;
71
- if (elapsedSeconds <= 0) {
72
- this.#pendingTokens += Math.max(0, totalTokens - this.#lastTotal);
73
- this.#lastTotal = totalTokens;
74
- return this.#rate;
75
- }
76
-
77
- const totalDelta = this.#pendingTokens + Math.max(0, totalTokens - this.#lastTotal);
78
- const instantRate = totalDelta / elapsedSeconds;
79
- this.#rate = EMA_ALPHA * instantRate + (1 - EMA_ALPHA) * this.#rate;
80
- this.#lastTotal = totalTokens;
81
- this.#lastTime = now;
82
- this.#pendingTokens = 0;
83
- return this.#rate;
84
- }
85
-
86
- reset(): void {
87
- this.#lastTotal = 0;
88
- this.#lastTime = 0;
89
- this.#rate = 0;
90
- this.#hasSample = false;
91
- this.#pendingTokens = 0;
92
- }
93
- }
94
-
95
- /** Eight-column scrolling activity meter, scrolling either left-to-right or right-to-left. */
96
- export class ActivityMeter {
97
- #levels: ActivityMeterLevel[] = Array<ActivityMeterLevel>(WIDTH).fill(0);
98
- #direction: "ltr" | "rtl";
99
-
100
- constructor(direction: "ltr" | "rtl" = "ltr") {
101
- this.#direction = direction;
102
- }
103
-
104
- setDirection(direction: "ltr" | "rtl"): void {
105
- if (direction !== this.#direction) {
106
- this.#direction = direction;
107
- // Reverse the existing data so the visual flow instantly flips.
108
- this.#levels.reverse();
109
- }
110
- }
111
-
112
- push(level: ActivityMeterLevel): void {
113
- if (this.#direction === "rtl") {
114
- this.#levels.shift();
115
- this.#levels.push(level);
116
- } else {
117
- this.#levels.pop();
118
- this.#levels.unshift(level);
119
- }
120
- }
121
-
122
- render(colorize?: CellColorizer): string {
123
- return this.#levels
124
- .map((level) => {
125
- const char = BRAILLE[level];
126
- return colorize ? colorize(level, char) : char;
127
- })
128
- .join("");
129
- }
130
-
131
- reset(): void {
132
- this.#levels.fill(0);
133
- }
134
-
135
- /** Colorize a meter cell (dim at IDLE, `colorize` otherwise). */
136
- static colorizeCell(
137
- level: ActivityMeterLevel,
138
- char: string,
139
- theme: { fg: (style: ThemeColor, s: string) => string },
140
- colorize: (text: string) => string,
141
- dimmed?: boolean,
142
- ): string {
143
- if (level === ActivityMeterLevel.IDLE) return theme.fg("dim", char);
144
- const colored = colorize(char);
145
- return dimmed ? dimAttribute(colored) : colored;
146
- }
147
- }