@pi-unipi/unipi 2.17.0 → 2.17.2

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/CHANGELOG.md CHANGED
@@ -6,7 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- ## [2.17.0] — 2026-09-15
9
+ ## [2.17.2] — 2026-09-15
10
+
11
+ ### Fixed
12
+
13
+ - `footer`: **the startup update prompt no longer becomes unclosable after ~3.5s.** The deferred Glance editor install (`installGlanceEditor` in `src/index.ts`) calls `setEditorComponent()`, which internally re-focuses the editor and silently stole keyboard focus from whatever overlay was open — the updater prompt kept rendering while `q`/`n`/Esc typed into the input box. The install now snapshots the focused overlay (`tui.isOverlayFocused()` / `getFocusedComponent()`) and restores it after the swap, protecting the updater prompt, the boot dashboard, and any other overlay. New `tests/glance-focus.test.ts`.
14
+ - `updater`: **the update prompt now shows the release notes for the version being offered.** It previously parsed the `CHANGELOG.md` shipped with the *installed* package, which by definition never contains the newer version's entry, so it only ever showed an empty `Unreleased` header. New `src/remote-changelog.ts` fetches `raw.githubusercontent.com/Neuron-Mr-White/unipi/v<latest>/CHANGELOG.md` (5s timeout, falls back to `main` when the tag is not pushed yet, then to the local file when offline) and caches tag-served content under `~/.unipi/cache/updater/changelog-<version>.md`. `parseChangelog()` is split into `parseChangelogContent()` + a file wrapper; `getNewerVersions()` drops an empty `Unreleased` entry; the empty state reads `No changelog available for <version> (offline?)`.
15
+
16
+ ## [2.17.1] — 2026-09-15
17
+
18
+ ### Fixed
19
+
20
+ - `fusion`: **the persisted Fusion pair is restored at startup.** `session_start` now re-applies the saved lead via `pi.setModel()` and restores its thinking level when pi boots on the sidekick model (previously the pair silently dropped to a single-model status); if the lead is unavailable it disables Fusion and warns instead of leaving a half-active pair.
21
+ - `fusion`: **leaving Fusion via `/model` persists.** Selecting a non-lead model now writes the single-model selection to the preset's `active`, so the session stays un-Fused across restarts instead of snapping back to the pair.
22
+ - `fusion`: **manual pricing overrides for un-priced models.** A new `prices` preset key (`{ "provider/id": { "input", "cachedInput", "output" } }`) lets you supply per-million prices for providers that report no pricing; savings estimation and the picker both consume the override. Zero/unavailable pricing now renders no slider marker and shows inline `no pricing data from provider` guidance instead of a misleading $0.
23
+ - `fusion`: **price-panel columns no longer run together.** Column width now reserves space for the longest header/value (e.g. `Sidekick cached input`) instead of a fixed cap, fixing truncated sidekick price headers.
10
24
 
11
25
  ### Added
12
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.17.0",
3
+ "version": "2.17.2",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -90,9 +90,9 @@
90
90
  "@pi-unipi/command-enchantment": "2.17.0",
91
91
  "@pi-unipi/compactor": "2.17.0",
92
92
  "@pi-unipi/core": "2.17.0",
93
- "@pi-unipi/footer": "2.17.0",
93
+ "@pi-unipi/footer": "2.17.2",
94
94
  "@pi-unipi/image": "2.17.0",
95
- "@pi-unipi/fusion": "2.17.0",
95
+ "@pi-unipi/fusion": "2.17.1",
96
96
  "@pi-unipi/info-screen": "2.17.0",
97
97
  "@pi-unipi/input-shortcuts": "2.17.0",
98
98
  "@pi-unipi/kanboard": "2.17.0",
@@ -103,7 +103,7 @@
103
103
  "@pi-unipi/ralph": "2.17.0",
104
104
  "@pi-unipi/subagents": "2.17.0",
105
105
  "@pi-unipi/trajectory": "2.17.0",
106
- "@pi-unipi/updater": "2.17.0",
106
+ "@pi-unipi/updater": "2.17.2",
107
107
  "@pi-unipi/utility": "2.17.0",
108
108
  "@pi-unipi/web-api": "2.17.0",
109
109
  "@pi-unipi/workflow": "2.17.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.17.0",
3
+ "version": "2.17.2",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -153,13 +153,10 @@ export default function footerExtension(pi: ExtensionAPI): void {
153
153
  // Glance-style input surface (pi-glance-inspired). Preserves all default
154
154
  // editor behavior via CustomEditor subclassing; only paint differs.
155
155
  //
156
- // FOCUS-SAFETY DEFERRAL: setEditorComponent() internally calls
157
- // ui.setFocus(newEditor). info-screen (loaded before us) opens its boot
158
- // dashboard during ITS session_start handler, so our session_start runs
159
- // while that overlay owns keyboard focus. Swapping now would steal focus
160
- // and strand the dashboard unclosable (q/Esc would type into the editor).
161
- // The boot overlay auto-closes after ~2s; we install after a grace period
162
- // longer than any sane bootTimeoutMs.
156
+ // FOCUS-SAFETY DEFERRAL: the timer remains a grace period for the boot
157
+ // dashboard, but installGlanceEditor also restores any overlay focus after
158
+ // setEditorComponent() calls ui.setFocus(newEditor). This covers the
159
+ // updater prompt too, so q/Esc cannot be stranded in the editor.
163
160
  state.glanceInstallTimer = setTimeout(() => installGlanceEditor(state, ctx), 3500);
164
161
 
165
162
  // Sync TPS cursor with persisted assistant messages so streaming-hook
@@ -420,6 +417,15 @@ function installGlanceEditor(
420
417
  ): void {
421
418
  if (st.glanceInstalled || !st.piContext || !st.glanceMode) return;
422
419
  try {
420
+ const tui = st.tuiRef as (import("@earendil-works/pi-tui").TUI & {
421
+ isOverlayFocused?: () => boolean;
422
+ getFocusedComponent?: () => import("@earendil-works/pi-tui").Component | null;
423
+ }) | null | undefined;
424
+ const overlayFocused = tui !== undefined && tui !== null
425
+ && (typeof tui.isOverlayFocused === "function" ? tui.isOverlayFocused() : tui.hasOverlay());
426
+ const overlayOwner = overlayFocused && typeof tui?.getFocusedComponent === "function"
427
+ ? tui.getFocusedComponent() ?? null
428
+ : null;
423
429
  const piCtx = st.piContext as Record<string, unknown> | undefined;
424
430
  const cwd = (piCtx?.sessionManager as any)?.getCwd?.() ?? (piCtx as any)?.cwd ?? process.cwd();
425
431
  const workspace = String(cwd).split("/").filter(Boolean).pop() ?? "~";
@@ -445,6 +451,7 @@ function installGlanceEditor(
445
451
  };
446
452
  }),
447
453
  );
454
+ if (overlayOwner && tui) tui.setFocus(overlayOwner);
448
455
  st.glanceInstalled = true;
449
456
  } catch {
450
457
  st.glanceInstalled = false;
@@ -117,11 +117,14 @@ savings above `$0.005`.
117
117
  "default": {"lead": "provider/lead", "sidekick": "provider/sidekick"},
118
118
  "effort": {"provider/sidekick": "high"},
119
119
  "badges": {"provider/sidekick": "new"},
120
+ "prices": {"provider/sidekick": {"input": 0.2, "cachedInput": 0.02, "output": 1.2}},
120
121
  "recent": ["provider/lead"],
121
122
  "active": {"kind": "fusion", "lead": "provider/lead", "sidekick": "provider/sidekick"}
122
123
  }
123
124
  ```
124
125
 
126
+ `prices` is an optional manual override for models whose provider reports no pricing.
127
+
125
128
  ## Status
126
129
 
127
130
  - [x] Preset store, project layering, curation UI, autocomplete boost
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/fusion",
3
- "version": "2.17.0",
3
+ "version": "2.17.1",
4
4
  "description": "Devin-style model picker, fusion presets (lead + sidekick), and Local Fusion runtime for UniPi",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,6 +21,7 @@ import { homedir } from "node:os";
21
21
  import { join } from "node:path";
22
22
  import {
23
23
  effortLabel,
24
+ globalPresetPath,
24
25
  isEffortLevel,
25
26
  loadPreset,
26
27
  modelKey,
@@ -60,20 +61,18 @@ function findModel(reg: Registry | undefined, key: string): Model<Api> | undefin
60
61
  return modelBykey.get(key);
61
62
  }
62
63
 
63
- function costOf(m: Model<Api> | undefined): PickerModel["cost"] {
64
- const cost = m?.cost;
65
- return cost && typeof cost.input === "number"
66
- ? { input: cost.input, cachedInput: cost.cacheRead ?? 0, output: cost.output }
67
- : undefined;
64
+ function costOf(m: Model<Api> | undefined, override?: PickerModel["cost"]): PickerModel["cost"] {
65
+ const cost = override ?? (m?.cost && typeof m.cost.input === "number" ? { input: m.cost.input, cachedInput: m.cost.cacheRead ?? 0, output: m.cost.output } : undefined);
66
+ return cost && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0) ? cost : undefined;
68
67
  }
69
68
 
70
- function toPickerModel(m: Model<Api>, badge?: FusionPreset["badges"][string]): PickerModel {
69
+ function toPickerModel(m: Model<Api>, badge?: FusionPreset["badges"][string], override?: PickerModel["cost"]): PickerModel {
71
70
  return {
72
71
  key: modelKey(m),
73
72
  name: m.name || m.id,
74
73
  provider: m.provider,
75
74
  badge,
76
- cost: costOf(m),
75
+ cost: costOf(m, override),
77
76
  reasoning: Boolean(m.reasoning),
78
77
  };
79
78
  }
@@ -130,9 +129,10 @@ export default function fusionExtension(pi: ExtensionAPI): void {
130
129
  function statusSavings(ctx: ExtensionContext): number | undefined {
131
130
  if (active?.kind !== "fusion" || runtime === undefined) return undefined;
132
131
  const reg = registryOf(ctx);
132
+ const preset = loadPreset(ctx.cwd ?? process.cwd()).preset;
133
133
  const lead = findModel(reg, active.lead);
134
134
  const side = findModel(reg, active.sidekick);
135
- return estimateSavings(runtime.usage, costOf(lead), costOf(side)).savedUsd;
135
+ return estimateSavings(runtime.usage, costOf(lead, preset.prices[active.lead]), costOf(side, preset.prices[active.sidekick])).savedUsd;
136
136
  }
137
137
 
138
138
  function publishStatus(ctx: ExtensionContext): void {
@@ -180,8 +180,14 @@ export default function fusionExtension(pi: ExtensionAPI): void {
180
180
  function savingsStats(ctx: ExtensionContext): string {
181
181
  if (active?.kind !== "fusion" || runtime === undefined) return "Fusion is not active — pick a Fusion pair with /unipi:model.";
182
182
  const reg = registryOf(ctx);
183
- const savings = estimateSavings(runtime.usage, costOf(findModel(reg, active.lead)), costOf(findModel(reg, active.sidekick)));
184
- return `Sidekick tokens: in ${String(runtime.usage.input)} · out ${String(runtime.usage.output)} · cached ${String(runtime.usage.cacheRead)} · cache write ${String(runtime.usage.cacheWrite)}\nSidekick cost: $${savings.sidekickUsd.toFixed(2)} · at lead prices: $${savings.atLeadUsd.toFixed(2)} · saved: $${savings.savedUsd.toFixed(2)}\nHandoffs: ${String(runtime.reports.size)} · runtime alive: ${String(runtime.isAlive())} · busy: ${String(runtime.isBusy())}`;
183
+ const preset = loadPreset(ctx.cwd ?? process.cwd()).preset;
184
+ const leadCost = costOf(findModel(reg, active.lead), preset.prices[active.lead]);
185
+ const sidekickCost = costOf(findModel(reg, active.sidekick), preset.prices[active.sidekick]);
186
+ const savings = estimateSavings(runtime.usage, leadCost, sidekickCost);
187
+ const pricing = leadCost === undefined && sidekickCost === undefined
188
+ ? '\nPricing unavailable from provider — set "prices" in ~/.unipi/config/fusion/preset.json to estimate savings.'
189
+ : "";
190
+ return `Sidekick tokens: in ${String(runtime.usage.input)} · out ${String(runtime.usage.output)} · cached ${String(runtime.usage.cacheRead)} · cache write ${String(runtime.usage.cacheWrite)}\nSidekick cost: $${savings.sidekickUsd.toFixed(2)} · at lead prices: $${savings.atLeadUsd.toFixed(2)} · saved: $${savings.savedUsd.toFixed(2)}\nHandoffs: ${String(runtime.reports.size)} · runtime alive: ${String(runtime.isAlive())} · busy: ${String(runtime.isBusy())}${pricing}`;
185
191
  }
186
192
 
187
193
  registerFusionTools(pi, {
@@ -267,7 +273,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
267
273
  const cwd = ctx.cwd ?? process.cwd();
268
274
  const loaded = loadPreset(cwd);
269
275
  const preset = loaded.preset;
270
- const models = reg.getAvailable().map((m) => toPickerModel(m, preset.badges[modelKey(m)]));
276
+ const models = reg.getAvailable().map((m) => toPickerModel(m, preset.badges[modelKey(m)], preset.prices[modelKey(m)]));
271
277
  if (models.length === 0) {
272
278
  ctx.ui.notify("No models available. Use /login to add a provider.", "warning");
273
279
  return;
@@ -347,13 +353,26 @@ export default function fusionExtension(pi: ExtensionAPI): void {
347
353
  },
348
354
  });
349
355
 
350
- pi.on("session_start", (_e, ctx) => {
356
+ pi.on("session_start", async (_e, ctx) => {
351
357
  stopRuntime();
352
358
  nudged = false;
353
359
  modelBykey.clear();
354
360
  active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
355
- // Only keep a Fusion status if the session actually runs on that lead.
356
- if (active?.kind === "fusion" && ctx.model && modelKey(ctx.model) !== active.lead) active = undefined;
361
+ if (active?.kind === "fusion" && (!ctx.model || modelKey(ctx.model) !== active.lead)) {
362
+ const leadKey = active.lead;
363
+ const lead = findModel(registryOf(ctx), leadKey);
364
+ const restored = lead !== undefined && await pi.setModel(lead);
365
+ if (restored) {
366
+ try {
367
+ pi.setThinkingLevel(active.leadEffort ?? "medium");
368
+ } catch {
369
+ /* provider may not support thinking */
370
+ }
371
+ } else {
372
+ active = undefined;
373
+ if (ctx.hasUI) ctx.ui.notify(`Fusion lead ${leadKey} unavailable — Fusion off`, "warning");
374
+ }
375
+ }
357
376
  publishStatus(ctx);
358
377
  if (ctx.hasUI) ctx.ui.addAutocompleteProvider(createModelBoostProvider);
359
378
  });
@@ -369,6 +388,8 @@ export default function fusionExtension(pi: ExtensionAPI): void {
369
388
  if (active?.kind === "fusion" && modelKey(event.model) !== active.lead) {
370
389
  stopRuntime();
371
390
  active = { kind: "single", model: modelKey(event.model) };
391
+ const loaded = loadPreset(ctx.cwd ?? process.cwd());
392
+ saveRuntimeState(globalPresetPath(), { effort: loaded.preset.effort, recent: loaded.preset.recent, active });
372
393
  publishStatus(ctx);
373
394
  }
374
395
  });
@@ -128,6 +128,10 @@ function money(perMillion: number): string {
128
128
  return `$${rounded.replace(/\.0+$/u, "").replace(/(\.\d)0$/u, "$1")} / 1M`;
129
129
  }
130
130
 
131
+ function hasPricing(cost: PickerModel["cost"]): cost is NonNullable<PickerModel["cost"]> {
132
+ return cost !== undefined && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0);
133
+ }
134
+
131
135
  function pad(text: string, width: number): string {
132
136
  const w = visibleWidth(text);
133
137
  return w >= width ? text : text + " ".repeat(width - w);
@@ -161,7 +165,7 @@ export class ModelPicker {
161
165
  this.onRenderRequest = options.onRenderRequest;
162
166
  this.visibleRows = options.visibleRows ?? DEFAULT_VISIBLE_ROWS;
163
167
  this.modelsByKey = new Map(options.state.models.map((m) => [m.key, m]));
164
- const prices = options.state.models.map((m) => (m.cost ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined);
168
+ const prices = options.state.models.map((m) => (hasPricing(m.cost) ? blendedPrice(m.cost) : undefined)).filter((p): p is number => p !== undefined && p > 0);
165
169
  this.priceRange = { min: prices.length > 0 ? Math.min(...prices) : 0, max: prices.length > 0 ? Math.max(...prices) : 0 };
166
170
  this.effort = { ...options.state.effort };
167
171
  const active = options.state.active;
@@ -477,24 +481,27 @@ export class ModelPicker {
477
481
  const primaryKey = row.kind === "fusion" ? this.lead : row.key;
478
482
  const primary = primaryKey === undefined ? undefined : this.modelsByKey.get(primaryKey);
479
483
  const side = row.kind === "fusion" && this.sidekick !== undefined ? this.modelsByKey.get(this.sidekick) : undefined;
484
+ const primaryCost = primary?.cost;
485
+ const sideCost = side?.cost;
480
486
  const cols: Array<[string, string]> = [];
481
- if (primary?.cost) {
482
- cols.push(["Input", money(primary.cost.input)]);
483
- cols.push(["Cached input", money(primary.cost.cachedInput)]);
484
- cols.push(["Output", money(primary.cost.output)]);
487
+ if (hasPricing(primaryCost)) {
488
+ cols.push(["Input", money(primaryCost.input)]);
489
+ cols.push(["Cached input", money(primaryCost.cachedInput)]);
490
+ cols.push(["Output", money(primaryCost.output)]);
485
491
  } else {
486
492
  cols.push(["Input", "—"], ["Cached input", "—"], ["Output", "—"]);
487
493
  }
488
494
  if (row.kind === "fusion") {
489
- if (side?.cost) {
490
- cols.push(["Sidekick input", money(side.cost.input)]);
491
- cols.push(["Sidekick cached input", money(side.cost.cachedInput)]);
492
- cols.push(["Sidekick output", money(side.cost.output)]);
495
+ if (hasPricing(sideCost)) {
496
+ cols.push(["Sidekick input", money(sideCost.input)]);
497
+ cols.push(["Sidekick cached input", money(sideCost.cachedInput)]);
498
+ cols.push(["Sidekick output", money(sideCost.output)]);
493
499
  } else {
494
500
  cols.push(["Sidekick input", "—"], ["Sidekick cached input", "—"], ["Sidekick output", "—"]);
495
501
  }
496
502
  }
497
- const colWidth = Math.max(10, Math.min(18, Math.floor((width - 4) / cols.length)));
503
+ const need = Math.max(...cols.map(([h, v]) => Math.max(visibleWidth(h), visibleWidth(v)))) + 3;
504
+ const colWidth = Math.max(10, Math.min(need, Math.floor((width - 4) / cols.length)));
498
505
  const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
499
506
  const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
500
507
  const desc =
@@ -506,7 +513,11 @@ export class ModelPicker {
506
513
  const badges = this.state.models.some((m) => m.badge !== undefined)
507
514
  ? `${t.fg("success", "✱")} ${t.fg("dim", "New")} ${t.fg("accent", "✱")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "✱")} ${t.fg("dim", "Beta")} ${t.fg("dim", "·")}`
508
515
  : "";
509
- const description = `${badges}${badges.length > 0 ? " " : ""}${desc}`;
516
+ const noPricing = row.kind === "fusion"
517
+ ? !hasPricing(primaryCost) || !hasPricing(sideCost)
518
+ : !hasPricing(primaryCost);
519
+ const pricing = noPricing ? t.fg("dim", " · no pricing data from provider") : "";
520
+ const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
510
521
  return [truncateToWidth(` ${head}`, width - 1), truncateToWidth(` ${vals}`, width - 1), truncateToWidth(` ${description}`, width - 1)];
511
522
  }
512
523
 
@@ -561,8 +572,9 @@ export class ModelPicker {
561
572
  const sliderCells = Math.min(48, Math.max(1, width - 6));
562
573
  const sliderKey = row?.kind === "fusion" ? this.lead : row?.key;
563
574
  const sliderModel = sliderKey === undefined ? undefined : this.modelsByKey.get(sliderKey);
564
- const sliderPrice = sliderModel?.cost === undefined ? undefined : blendedPrice(sliderModel.cost);
565
- const marker = sliderPrice === undefined ? undefined : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
575
+ const sliderCost = sliderModel?.cost;
576
+ const sliderPrice = hasPricing(sliderCost) ? blendedPrice(sliderCost) : undefined;
577
+ const marker = this.priceRange.max <= 0 || sliderPrice === undefined ? undefined : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
566
578
  lines.push(truncateToWidth(` ${renderSlider(sliderCells, marker)}`, width - 1));
567
579
  lines.push(...this.renderPricePanel(row, width));
568
580
  lines.push("");
@@ -61,6 +61,8 @@ export interface FusionPreset {
61
61
  recent: ModelKey[];
62
62
  /** Optional hand-curated model badge metadata. */
63
63
  badges: Record<ModelKey, FusionBadge>;
64
+ /** Manual pricing overrides for providers that report no pricing. */
65
+ prices: Record<ModelKey, { input: number; cachedInput: number; output: number }>;
64
66
  /** What the user last confirmed in the picker. */
65
67
  active?: ActiveSelection | undefined;
66
68
  }
@@ -74,6 +76,7 @@ export function emptyPreset(): FusionPreset {
74
76
  effort: {},
75
77
  recent: [],
76
78
  badges: {},
79
+ prices: {},
77
80
  };
78
81
  }
79
82
 
@@ -136,6 +139,20 @@ export function parsePreset(raw: unknown): Partial<FusionPreset> {
136
139
  }
137
140
  out.badges = badges;
138
141
  }
142
+ if (typeof r["prices"] === "object" && r["prices"] !== null) {
143
+ const prices: FusionPreset["prices"] = {};
144
+ for (const [k, v] of Object.entries(r["prices"] as Record<string, unknown>)) {
145
+ if (typeof v !== "object" || v === null) continue;
146
+ const price = v as Record<string, unknown>;
147
+ const input = price["input"];
148
+ const cachedInput = price["cachedInput"];
149
+ const output = price["output"];
150
+ if ([input, cachedInput, output].every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0)) {
151
+ prices[k] = { input: input as number, cachedInput: cachedInput as number, output: output as number };
152
+ }
153
+ }
154
+ out.prices = prices;
155
+ }
139
156
  const active = r["active"];
140
157
  if (typeof active === "object" && active !== null) {
141
158
  const a = active as Record<string, unknown>;
@@ -167,6 +184,7 @@ export function mergePresets(base: FusionPreset, over: Partial<FusionPreset>): F
167
184
  effort: { ...base.effort, ...(over.effort ?? {}) },
168
185
  recent: over.recent ?? base.recent,
169
186
  badges: { ...base.badges, ...(over.badges ?? {}) },
187
+ prices: { ...base.prices, ...(over.prices ?? {}) },
170
188
  active: over.active ?? base.active,
171
189
  };
172
190
  }
@@ -6978,7 +6978,7 @@ var init_hooks = __esm({
6978
6978
  if (!debug) return;
6979
6979
  try {
6980
6980
  import("node:fs").then(
6981
- ({ writeFileSync: writeFileSync48 }) => writeFileSync48("/tmp/compactor-debug.json", JSON.stringify(data, null, 2))
6981
+ ({ writeFileSync: writeFileSync49 }) => writeFileSync49("/tmp/compactor-debug.json", JSON.stringify(data, null, 2))
6982
6982
  ).catch(() => {
6983
6983
  });
6984
6984
  } catch {
@@ -15246,8 +15246,8 @@ function resolvePiPackageRoot() {
15246
15246
  return void 0;
15247
15247
  }
15248
15248
  }
15249
- function isRunnableNodeScript(filePath, existsSync64) {
15250
- if (!existsSync64(filePath)) return false;
15249
+ function isRunnableNodeScript(filePath, existsSync65) {
15250
+ if (!existsSync65(filePath)) return false;
15251
15251
  return /\.(?:mjs|cjs|js)$/i.test(filePath);
15252
15252
  }
15253
15253
  function normalizePath(filePath) {
@@ -15258,16 +15258,16 @@ function isStandalonePiExecutable(execPath) {
15258
15258
  return /^pi(?:\.exe)?$/i.test(executableName ?? "");
15259
15259
  }
15260
15260
  function resolvePiCliScript(deps = {}) {
15261
- const existsSync64 = deps.existsSync ?? fs9.existsSync;
15261
+ const existsSync65 = deps.existsSync ?? fs9.existsSync;
15262
15262
  const realpathSync9 = deps.realpathSync ?? fs9.realpathSync;
15263
- const readFileSync55 = deps.readFileSync ?? ((filePath, encoding) => fs9.readFileSync(filePath, encoding));
15263
+ const readFileSync56 = deps.readFileSync ?? ((filePath, encoding) => fs9.readFileSync(filePath, encoding));
15264
15264
  const argv1 = deps.argv1 ?? process.argv[1];
15265
15265
  if (argv1) {
15266
15266
  const argvPath = normalizePath(argv1);
15267
- if (isRunnableNodeScript(argvPath, existsSync64)) {
15267
+ if (isRunnableNodeScript(argvPath, existsSync65)) {
15268
15268
  try {
15269
15269
  const canonicalArgvPath = realpathSync9(argvPath);
15270
- if (isRunnableNodeScript(canonicalArgvPath, existsSync64) && findPiPackageRootFromEntry(canonicalArgvPath)) {
15270
+ if (isRunnableNodeScript(canonicalArgvPath, existsSync65) && findPiPackageRootFromEntry(canonicalArgvPath)) {
15271
15271
  return canonicalArgvPath;
15272
15272
  }
15273
15273
  } catch {
@@ -15285,12 +15285,12 @@ function resolvePiCliScript(deps = {}) {
15285
15285
  return path9.join(packageRoot, "package.json");
15286
15286
  });
15287
15287
  const packageJsonPath = resolvePackageJson();
15288
- const packageJson = JSON.parse(readFileSync55(packageJsonPath, "utf8"));
15288
+ const packageJson = JSON.parse(readFileSync56(packageJsonPath, "utf8"));
15289
15289
  const binField = packageJson.bin;
15290
15290
  const binPath = typeof binField === "string" ? binField : binField?.pi ?? Object.values(binField ?? {})[0];
15291
15291
  if (!binPath) return void 0;
15292
15292
  const candidate = path9.resolve(path9.dirname(packageJsonPath), binPath);
15293
- if (isRunnableNodeScript(candidate, existsSync64)) {
15293
+ if (isRunnableNodeScript(candidate, existsSync65)) {
15294
15294
  return candidate;
15295
15295
  }
15296
15296
  } catch {
@@ -42422,15 +42422,15 @@ function compactorExtension(pi) {
42422
42422
  config = loadConfig4(cwd);
42423
42423
  currentSessionId = `${ctx.sessionId ?? "default"}${getWorktreeSuffix()}`;
42424
42424
  try {
42425
- const { existsSync: existsSync64 } = await import("node:fs");
42426
- const { join: join77 } = await import("node:path");
42425
+ const { existsSync: existsSync65 } = await import("node:fs");
42426
+ const { join: join78 } = await import("node:path");
42427
42427
  const strategies = [
42428
42428
  { key: "commits", config: config.commits }
42429
42429
  ];
42430
42430
  for (const { key, config: strat } of strategies) {
42431
42431
  if (strat.autoDetect === "git") {
42432
- const gitDir = join77(cwd, ".git");
42433
- if (!existsSync64(gitDir)) {
42432
+ const gitDir = join78(cwd, ".git");
42433
+ if (!existsSync65(gitDir)) {
42434
42434
  strat.enabled = false;
42435
42435
  }
42436
42436
  }
@@ -46449,11 +46449,14 @@ function applyGlanceMode(st, cmdCtx) {
46449
46449
  function installGlanceEditor(st, uiHost) {
46450
46450
  if (st.glanceInstalled || !st.piContext || !st.glanceMode) return;
46451
46451
  try {
46452
+ const tui = st.tuiRef;
46453
+ const overlayFocused = tui !== void 0 && tui !== null && (typeof tui.isOverlayFocused === "function" ? tui.isOverlayFocused() : tui.hasOverlay());
46454
+ const overlayOwner = overlayFocused && typeof tui?.getFocusedComponent === "function" ? tui.getFocusedComponent() ?? null : null;
46452
46455
  const piCtx = st.piContext;
46453
46456
  const cwd = piCtx?.sessionManager?.getCwd?.() ?? piCtx?.cwd ?? process.cwd();
46454
46457
  const workspace = String(cwd).split("/").filter(Boolean).pop() ?? "~";
46455
46458
  uiHost.ui.setEditorComponent(
46456
- (tui, theme, keybindings) => new GlanceEditor(tui, theme, keybindings, () => {
46459
+ (tui2, theme, keybindings) => new GlanceEditor(tui2, theme, keybindings, () => {
46457
46460
  const p = st.piContext;
46458
46461
  const usage2 = typeof p?.getContextUsage === "function" ? p.getContextUsage() : void 0;
46459
46462
  const model = p?.model;
@@ -46472,6 +46475,7 @@ function installGlanceEditor(st, uiHost) {
46472
46475
  };
46473
46476
  })
46474
46477
  );
46478
+ if (overlayOwner && tui) tui.setFocus(overlayOwner);
46475
46479
  st.glanceInstalled = true;
46476
46480
  } catch {
46477
46481
  st.glanceInstalled = false;
@@ -46968,9 +46972,12 @@ var VERSION_HEADER_RE = /^## \[(.+?)\](?:\s*[-—–]\s*(.+))?$/;
46968
46972
  var SECTION_HEADER_RE = /^### (.+)$/;
46969
46973
  function parseChangelog(filePath) {
46970
46974
  if (!existsSync56(filePath)) return [];
46971
- const content = readFileSync47(filePath, "utf-8").trim();
46972
- if (!content) return [];
46973
- const lines = content.split("\n");
46975
+ return parseChangelogContent(readFileSync47(filePath, "utf-8"));
46976
+ }
46977
+ function parseChangelogContent(content) {
46978
+ const trimmed = content.trim();
46979
+ if (!trimmed) return [];
46980
+ const lines = trimmed.split("\n");
46974
46981
  const entries = [];
46975
46982
  let currentEntry = null;
46976
46983
  let currentSection = null;
@@ -47006,12 +47013,12 @@ function parseChangelog(filePath) {
47006
47013
  currentItems = [];
47007
47014
  continue;
47008
47015
  }
47009
- const trimmed = line.trim();
47010
- if (currentSection && (trimmed.startsWith("- ") || trimmed.startsWith("* "))) {
47011
- currentItems.push(trimmed.slice(2).trim());
47012
- } else if (trimmed && currentSection) {
47016
+ const trimmed2 = line.trim();
47017
+ if (currentSection && (trimmed2.startsWith("- ") || trimmed2.startsWith("* "))) {
47018
+ currentItems.push(trimmed2.slice(2).trim());
47019
+ } else if (trimmed2 && currentSection) {
47013
47020
  if (currentItems.length > 0) {
47014
- currentItems[currentItems.length - 1] += " " + trimmed;
47021
+ currentItems[currentItems.length - 1] += " " + trimmed2;
47015
47022
  }
47016
47023
  }
47017
47024
  }
@@ -47038,7 +47045,7 @@ function getNewerVersions(entries, installedVersion) {
47038
47045
  const result2 = [];
47039
47046
  for (const entry of entries) {
47040
47047
  if (entry.version === "Unreleased") {
47041
- result2.push(entry);
47048
+ if (Object.keys(entry.sections).length > 0) result2.push(entry);
47042
47049
  continue;
47043
47050
  }
47044
47051
  if (!isNewerVersion(entry.version, installedVersion)) break;
@@ -47475,15 +47482,17 @@ function padVisible3(content, targetWidth) {
47475
47482
  const pad2 = Math.max(0, targetWidth - vw);
47476
47483
  return content + " ".repeat(pad2);
47477
47484
  }
47478
- function renderUpdateOverlay(checkResult) {
47485
+ function renderUpdateOverlay(checkResult, providedNewerVersions) {
47479
47486
  return (tui, theme, _kb, done) => {
47480
47487
  const config = loadConfig5();
47481
- let newerVersions = [];
47482
- const changelogPath = resolveChangelogPath();
47483
- try {
47484
- const entries = parseChangelog(changelogPath);
47485
- newerVersions = getNewerVersions(entries, checkResult.currentVersion);
47486
- } catch (_err) {
47488
+ let newerVersions = providedNewerVersions ?? [];
47489
+ if (providedNewerVersions === void 0) {
47490
+ const changelogPath = resolveChangelogPath();
47491
+ try {
47492
+ const entries = parseChangelog(changelogPath);
47493
+ newerVersions = getNewerVersions(entries, checkResult.currentVersion);
47494
+ } catch (_err) {
47495
+ }
47487
47496
  }
47488
47497
  const contentLines = [];
47489
47498
  for (const entry of newerVersions) {
@@ -47496,7 +47505,7 @@ function renderUpdateOverlay(checkResult) {
47496
47505
  contentLines.push("");
47497
47506
  }
47498
47507
  if (contentLines.length === 0) {
47499
- contentLines.push(` ${theme.fg("muted", "No changelog available for this update.")}`);
47508
+ contentLines.push(` ${theme.fg("muted", `No changelog available for ${checkResult.latestVersion} (offline?).`)}`);
47500
47509
  }
47501
47510
  const state2 = {
47502
47511
  result: checkResult,
@@ -47633,6 +47642,49 @@ function renderUpdateOverlay(checkResult) {
47633
47642
  };
47634
47643
  }
47635
47644
 
47645
+ // packages/updater/src/remote-changelog.ts
47646
+ init_core();
47647
+ import { existsSync as existsSync60, mkdirSync as mkdirSync38, readFileSync as readFileSync50, writeFileSync as writeFileSync42 } from "node:fs";
47648
+ import { homedir as homedir34 } from "node:os";
47649
+ import { join as join69 } from "node:path";
47650
+ var CHANGELOG_RAW_BASE = "https://raw.githubusercontent.com/Neuron-Mr-White/unipi";
47651
+ function cacheDirectory(opts) {
47652
+ return (opts?.cacheDir ?? UPDATER_DIRS.CACHE).replace("~", homedir34());
47653
+ }
47654
+ async function fetchRemoteChangelog(version, opts = {}) {
47655
+ const cacheDir2 = cacheDirectory(opts);
47656
+ const cachePath = join69(cacheDir2, `changelog-${version}.md`);
47657
+ try {
47658
+ if (existsSync60(cachePath)) return readFileSync50(cachePath, "utf8");
47659
+ } catch {
47660
+ }
47661
+ const fetchImpl = opts.fetchImpl ?? fetch;
47662
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 5e3);
47663
+ try {
47664
+ let response = await fetchImpl(`${CHANGELOG_RAW_BASE}/v${version}/CHANGELOG.md`, { signal });
47665
+ const cacheable = response.ok;
47666
+ if (response.status === 404) {
47667
+ response = await fetchImpl(`${CHANGELOG_RAW_BASE}/main/CHANGELOG.md`, { signal });
47668
+ }
47669
+ if (!response.ok) return null;
47670
+ const content = await response.text();
47671
+ if (cacheable) {
47672
+ mkdirSync38(cacheDir2, { recursive: true });
47673
+ writeFileSync42(cachePath, content, "utf8");
47674
+ }
47675
+ return content;
47676
+ } catch {
47677
+ return null;
47678
+ }
47679
+ }
47680
+ async function loadUpdateChangelog(currentVersion, latestVersion, opts) {
47681
+ const remote = await fetchRemoteChangelog(latestVersion, opts);
47682
+ const entries = remote === null ? [] : parseChangelogContent(remote);
47683
+ const newer = remote === null ? [] : getNewerVersions(entries, currentVersion);
47684
+ if (newer.length > 0) return newer;
47685
+ return getNewerVersions(parseChangelog(resolveChangelogPath()), currentVersion);
47686
+ }
47687
+
47636
47688
  // packages/updater/src/index.ts
47637
47689
  var VERSION10 = getPackageVersion(new URL("..", import.meta.url).pathname);
47638
47690
  function updaterExtension(pi) {
@@ -47726,8 +47778,9 @@ function updaterExtension(pi) {
47726
47778
  latestVersion: result2.latestVersion
47727
47779
  });
47728
47780
  if (ctx.hasUI) {
47781
+ const entries = await loadUpdateChangelog(result2.currentVersion, result2.latestVersion);
47729
47782
  const updateResult = await ctx.ui.custom(
47730
- renderUpdateOverlay(result2),
47783
+ renderUpdateOverlay(result2, entries),
47731
47784
  {
47732
47785
  overlay: true,
47733
47786
  overlayOptions: {
@@ -47755,8 +47808,8 @@ init_core();
47755
47808
  import { Key as Key19, matchesKey as matchesKey25 } from "@earendil-works/pi-tui";
47756
47809
 
47757
47810
  // packages/input-shortcuts/src/registers.ts
47758
- import { existsSync as existsSync60, mkdirSync as mkdirSync38, readFileSync as readFileSync50, renameSync as renameSync13, writeFileSync as writeFileSync42 } from "node:fs";
47759
- import { dirname as dirname45, join as join69 } from "node:path";
47811
+ import { existsSync as existsSync61, mkdirSync as mkdirSync39, readFileSync as readFileSync51, renameSync as renameSync13, writeFileSync as writeFileSync43 } from "node:fs";
47812
+ import { dirname as dirname45, join as join70 } from "node:path";
47760
47813
 
47761
47814
  // packages/input-shortcuts/src/types.ts
47762
47815
  var DEFAULT_CONFIG8 = {
@@ -47779,7 +47832,7 @@ var RegisterStore = class {
47779
47832
  filePath;
47780
47833
  loaded = false;
47781
47834
  constructor(baseDir) {
47782
- this.filePath = baseDir ? join69(baseDir, REGISTERS_FILE) : REGISTERS_FILE;
47835
+ this.filePath = baseDir ? join70(baseDir, REGISTERS_FILE) : REGISTERS_FILE;
47783
47836
  }
47784
47837
  /** Get the stash register contents. */
47785
47838
  getStash() {
@@ -47803,8 +47856,8 @@ var RegisterStore = class {
47803
47856
  if (this.loaded) return;
47804
47857
  this.loaded = true;
47805
47858
  try {
47806
- if (existsSync60(this.filePath)) {
47807
- const raw = readFileSync50(this.filePath, "utf-8");
47859
+ if (existsSync61(this.filePath)) {
47860
+ const raw = readFileSync51(this.filePath, "utf-8");
47808
47861
  const parsed = JSON.parse(raw);
47809
47862
  this.data = {
47810
47863
  stash: typeof parsed.stash === "string" ? parsed.stash : "",
@@ -47821,11 +47874,11 @@ var RegisterStore = class {
47821
47874
  save() {
47822
47875
  try {
47823
47876
  const dir = dirname45(this.filePath);
47824
- if (!existsSync60(dir)) {
47825
- mkdirSync38(dir, { recursive: true });
47877
+ if (!existsSync61(dir)) {
47878
+ mkdirSync39(dir, { recursive: true });
47826
47879
  }
47827
47880
  const tmpPath = this.filePath + ".tmp";
47828
- writeFileSync42(tmpPath, JSON.stringify(this.data, null, 2), "utf-8");
47881
+ writeFileSync43(tmpPath, JSON.stringify(this.data, null, 2), "utf-8");
47829
47882
  renameSync13(tmpPath, this.filePath);
47830
47883
  } catch {
47831
47884
  }
@@ -48037,8 +48090,8 @@ var ChordOverlay = class extends Container2 {
48037
48090
 
48038
48091
  // packages/input-shortcuts/src/settings-overlay.ts
48039
48092
  import { SettingsList as SettingsList3 } from "@earendil-works/pi-tui";
48040
- import { existsSync as existsSync61, mkdirSync as mkdirSync39, readFileSync as readFileSync51, renameSync as renameSync14, writeFileSync as writeFileSync43 } from "node:fs";
48041
- import { dirname as dirname46, join as join70 } from "node:path";
48093
+ import { existsSync as existsSync62, mkdirSync as mkdirSync40, readFileSync as readFileSync52, renameSync as renameSync14, writeFileSync as writeFileSync44 } from "node:fs";
48094
+ import { dirname as dirname46, join as join71 } from "node:path";
48042
48095
  var ALT_KEY_OPTIONS = [
48043
48096
  "alt+a",
48044
48097
  "alt+b",
@@ -48070,10 +48123,10 @@ var ALT_KEY_OPTIONS = [
48070
48123
  var CONFLICTS = /* @__PURE__ */ new Set(["alt+e"]);
48071
48124
  var FREE_ALT_KEYS = ALT_KEY_OPTIONS.filter((k) => !CONFLICTS.has(k));
48072
48125
  function loadConfig6(baseDir) {
48073
- const filePath = baseDir ? join70(baseDir, CONFIG_FILE) : CONFIG_FILE;
48126
+ const filePath = baseDir ? join71(baseDir, CONFIG_FILE) : CONFIG_FILE;
48074
48127
  try {
48075
- if (existsSync61(filePath)) {
48076
- const raw = readFileSync51(filePath, "utf-8");
48128
+ if (existsSync62(filePath)) {
48129
+ const raw = readFileSync52(filePath, "utf-8");
48077
48130
  const parsed = JSON.parse(raw);
48078
48131
  return {
48079
48132
  chordKey: typeof parsed.chordKey === "string" ? parsed.chordKey : DEFAULT_CONFIG8.chordKey,
@@ -48085,14 +48138,14 @@ function loadConfig6(baseDir) {
48085
48138
  return { ...DEFAULT_CONFIG8 };
48086
48139
  }
48087
48140
  function saveConfig5(config, baseDir) {
48088
- const filePath = baseDir ? join70(baseDir, CONFIG_FILE) : CONFIG_FILE;
48141
+ const filePath = baseDir ? join71(baseDir, CONFIG_FILE) : CONFIG_FILE;
48089
48142
  try {
48090
48143
  const dir = dirname46(filePath);
48091
- if (!existsSync61(dir)) {
48092
- mkdirSync39(dir, { recursive: true });
48144
+ if (!existsSync62(dir)) {
48145
+ mkdirSync40(dir, { recursive: true });
48093
48146
  }
48094
48147
  const tmpPath = filePath + ".tmp";
48095
- writeFileSync43(tmpPath, JSON.stringify(config, null, 2), "utf-8");
48148
+ writeFileSync44(tmpPath, JSON.stringify(config, null, 2), "utf-8");
48096
48149
  renameSync14(tmpPath, filePath);
48097
48150
  } catch {
48098
48151
  }
@@ -50304,13 +50357,13 @@ function src_default5(pi) {
50304
50357
 
50305
50358
  // packages/fusion/src/index.ts
50306
50359
  init_core();
50307
- import { homedir as homedir36 } from "node:os";
50308
- import { join as join75 } from "node:path";
50360
+ import { homedir as homedir37 } from "node:os";
50361
+ import { join as join76 } from "node:path";
50309
50362
 
50310
50363
  // packages/fusion/src/preset.ts
50311
- import { existsSync as existsSync63, mkdirSync as mkdirSync42, readFileSync as readFileSync54, writeFileSync as writeFileSync46, renameSync as renameSync15 } from "node:fs";
50312
- import { homedir as homedir35 } from "node:os";
50313
- import { dirname as dirname48, join as join73 } from "node:path";
50364
+ import { existsSync as existsSync64, mkdirSync as mkdirSync43, readFileSync as readFileSync55, writeFileSync as writeFileSync47, renameSync as renameSync15 } from "node:fs";
50365
+ import { homedir as homedir36 } from "node:os";
50366
+ import { dirname as dirname48, join as join74 } from "node:path";
50314
50367
  var PRESET_SCHEMA_VERSION = 1;
50315
50368
  var EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
50316
50369
  var RECENT_LIMIT = 5;
@@ -50322,14 +50375,15 @@ function emptyPreset() {
50322
50375
  default: {},
50323
50376
  effort: {},
50324
50377
  recent: [],
50325
- badges: {}
50378
+ badges: {},
50379
+ prices: {}
50326
50380
  };
50327
50381
  }
50328
- function globalPresetPath(home = homedir35()) {
50329
- return join73(home, ".unipi", "config", "fusion", "preset.json");
50382
+ function globalPresetPath(home = homedir36()) {
50383
+ return join74(home, ".unipi", "config", "fusion", "preset.json");
50330
50384
  }
50331
50385
  function projectPresetPath(cwd) {
50332
- return join73(cwd, ".unipi", "fusion-preset.json");
50386
+ return join74(cwd, ".unipi", "fusion-preset.json");
50333
50387
  }
50334
50388
  function modelKey(model) {
50335
50389
  return `${model.provider}/${model.id}`;
@@ -50377,6 +50431,20 @@ function parsePreset2(raw) {
50377
50431
  }
50378
50432
  out.badges = badges;
50379
50433
  }
50434
+ if (typeof r["prices"] === "object" && r["prices"] !== null) {
50435
+ const prices = {};
50436
+ for (const [k, v] of Object.entries(r["prices"])) {
50437
+ if (typeof v !== "object" || v === null) continue;
50438
+ const price = v;
50439
+ const input = price["input"];
50440
+ const cachedInput = price["cachedInput"];
50441
+ const output = price["output"];
50442
+ if ([input, cachedInput, output].every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0)) {
50443
+ prices[k] = { input, cachedInput, output };
50444
+ }
50445
+ }
50446
+ out.prices = prices;
50447
+ }
50380
50448
  const active = r["active"];
50381
50449
  if (typeof active === "object" && active !== null) {
50382
50450
  const a = active;
@@ -50403,18 +50471,19 @@ function mergePresets(base, over) {
50403
50471
  effort: { ...base.effort, ...over.effort ?? {} },
50404
50472
  recent: over.recent ?? base.recent,
50405
50473
  badges: { ...base.badges, ...over.badges ?? {} },
50474
+ prices: { ...base.prices, ...over.prices ?? {} },
50406
50475
  active: over.active ?? base.active
50407
50476
  };
50408
50477
  }
50409
50478
  function readJson4(path41) {
50410
50479
  try {
50411
- if (!existsSync63(path41)) return void 0;
50412
- return JSON.parse(readFileSync54(path41, "utf8"));
50480
+ if (!existsSync64(path41)) return void 0;
50481
+ return JSON.parse(readFileSync55(path41, "utf8"));
50413
50482
  } catch {
50414
50483
  return void 0;
50415
50484
  }
50416
50485
  }
50417
- function loadPreset(cwd, home = homedir35()) {
50486
+ function loadPreset(cwd, home = homedir36()) {
50418
50487
  const globalPath = globalPresetPath(home);
50419
50488
  const projectPath = projectPresetPath(cwd);
50420
50489
  const globalRaw = readJson4(globalPath);
@@ -50425,9 +50494,9 @@ function loadPreset(cwd, home = homedir35()) {
50425
50494
  return { preset: preset2, globalPath, projectPath, hasProjectLayer };
50426
50495
  }
50427
50496
  function writeJsonAtomic2(path41, value) {
50428
- mkdirSync42(dirname48(path41), { recursive: true });
50497
+ mkdirSync43(dirname48(path41), { recursive: true });
50429
50498
  const tmp = `${path41}.${String(process.pid)}.tmp`;
50430
- writeFileSync46(tmp, `${JSON.stringify(value, null, 2)}
50499
+ writeFileSync47(tmp, `${JSON.stringify(value, null, 2)}
50431
50500
  `, "utf8");
50432
50501
  renameSync15(tmp, path41);
50433
50502
  }
@@ -50522,6 +50591,9 @@ function money(perMillion) {
50522
50591
  const rounded = perMillion >= 10 ? perMillion.toFixed(0) : perMillion >= 1 ? perMillion.toFixed(1) : perMillion.toFixed(2);
50523
50592
  return `$${rounded.replace(/\.0+$/u, "").replace(/(\.\d)0$/u, "$1")} / 1M`;
50524
50593
  }
50594
+ function hasPricing(cost) {
50595
+ return cost !== void 0 && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0);
50596
+ }
50525
50597
  function pad(text, width) {
50526
50598
  const w = visibleWidth23(text);
50527
50599
  return w >= width ? text : text + " ".repeat(width - w);
@@ -50552,7 +50624,7 @@ var ModelPicker = class {
50552
50624
  this.onRenderRequest = options.onRenderRequest;
50553
50625
  this.visibleRows = options.visibleRows ?? DEFAULT_VISIBLE_ROWS;
50554
50626
  this.modelsByKey = new Map(options.state.models.map((m) => [m.key, m]));
50555
- const prices = options.state.models.map((m) => m.cost ? blendedPrice(m.cost) : void 0).filter((p) => p !== void 0);
50627
+ const prices = options.state.models.map((m) => hasPricing(m.cost) ? blendedPrice(m.cost) : void 0).filter((p) => p !== void 0 && p > 0);
50556
50628
  this.priceRange = { min: prices.length > 0 ? Math.min(...prices) : 0, max: prices.length > 0 ? Math.max(...prices) : 0 };
50557
50629
  this.effort = { ...options.state.effort };
50558
50630
  const active = options.state.active;
@@ -50802,29 +50874,34 @@ var ModelPicker = class {
50802
50874
  const primaryKey = row.kind === "fusion" ? this.lead : row.key;
50803
50875
  const primary = primaryKey === void 0 ? void 0 : this.modelsByKey.get(primaryKey);
50804
50876
  const side = row.kind === "fusion" && this.sidekick !== void 0 ? this.modelsByKey.get(this.sidekick) : void 0;
50877
+ const primaryCost = primary?.cost;
50878
+ const sideCost = side?.cost;
50805
50879
  const cols = [];
50806
- if (primary?.cost) {
50807
- cols.push(["Input", money(primary.cost.input)]);
50808
- cols.push(["Cached input", money(primary.cost.cachedInput)]);
50809
- cols.push(["Output", money(primary.cost.output)]);
50880
+ if (hasPricing(primaryCost)) {
50881
+ cols.push(["Input", money(primaryCost.input)]);
50882
+ cols.push(["Cached input", money(primaryCost.cachedInput)]);
50883
+ cols.push(["Output", money(primaryCost.output)]);
50810
50884
  } else {
50811
50885
  cols.push(["Input", "\u2014"], ["Cached input", "\u2014"], ["Output", "\u2014"]);
50812
50886
  }
50813
50887
  if (row.kind === "fusion") {
50814
- if (side?.cost) {
50815
- cols.push(["Sidekick input", money(side.cost.input)]);
50816
- cols.push(["Sidekick cached input", money(side.cost.cachedInput)]);
50817
- cols.push(["Sidekick output", money(side.cost.output)]);
50888
+ if (hasPricing(sideCost)) {
50889
+ cols.push(["Sidekick input", money(sideCost.input)]);
50890
+ cols.push(["Sidekick cached input", money(sideCost.cachedInput)]);
50891
+ cols.push(["Sidekick output", money(sideCost.output)]);
50818
50892
  } else {
50819
50893
  cols.push(["Sidekick input", "\u2014"], ["Sidekick cached input", "\u2014"], ["Sidekick output", "\u2014"]);
50820
50894
  }
50821
50895
  }
50822
- const colWidth = Math.max(10, Math.min(18, Math.floor((width - 4) / cols.length)));
50896
+ const need = Math.max(...cols.map(([h, v]) => Math.max(visibleWidth23(h), visibleWidth23(v)))) + 3;
50897
+ const colWidth = Math.max(10, Math.min(need, Math.floor((width - 4) / cols.length)));
50823
50898
  const head = cols.map(([h]) => pad(t.fg("dim", h), colWidth)).join("");
50824
50899
  const vals = cols.map(([, v]) => pad(t.fg("text", v), colWidth)).join("");
50825
50900
  const desc = row.kind === "fusion" ? t.fg("dim", "Pairs frontier intelligence with cost-efficient execution") : primary?.reasoning ? t.fg("dim", "Reasoning model \xB7 \u2190/\u2192 adjusts thinking effort") : t.fg("dim", "Non-reasoning model \xB7 effort is ignored by the provider");
50826
50901
  const badges = this.state.models.some((m) => m.badge !== void 0) ? `${t.fg("success", "\u2731")} ${t.fg("dim", "New")} ${t.fg("accent", "\u2731")} ${t.fg("dim", "Promotion")} ${t.fg("warning", "\u2731")} ${t.fg("dim", "Beta")} ${t.fg("dim", "\xB7")}` : "";
50827
- const description = `${badges}${badges.length > 0 ? " " : ""}${desc}`;
50902
+ const noPricing = row.kind === "fusion" ? !hasPricing(primaryCost) || !hasPricing(sideCost) : !hasPricing(primaryCost);
50903
+ const pricing = noPricing ? t.fg("dim", " \xB7 no pricing data from provider") : "";
50904
+ const description = `${badges}${badges.length > 0 ? " " : ""}${desc}${pricing}`;
50828
50905
  return [truncateToWidth26(` ${head}`, width - 1), truncateToWidth26(` ${vals}`, width - 1), truncateToWidth26(` ${description}`, width - 1)];
50829
50906
  }
50830
50907
  hintLine(row) {
@@ -50873,8 +50950,9 @@ var ModelPicker = class {
50873
50950
  const sliderCells = Math.min(48, Math.max(1, width - 6));
50874
50951
  const sliderKey = row?.kind === "fusion" ? this.lead : row?.key;
50875
50952
  const sliderModel = sliderKey === void 0 ? void 0 : this.modelsByKey.get(sliderKey);
50876
- const sliderPrice = sliderModel?.cost === void 0 ? void 0 : blendedPrice(sliderModel.cost);
50877
- const marker = sliderPrice === void 0 ? void 0 : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
50953
+ const sliderCost = sliderModel?.cost;
50954
+ const sliderPrice = hasPricing(sliderCost) ? blendedPrice(sliderCost) : void 0;
50955
+ const marker = this.priceRange.max <= 0 || sliderPrice === void 0 ? void 0 : sliderPosition(sliderPrice, this.priceRange.min, this.priceRange.max, sliderCells);
50878
50956
  lines.push(truncateToWidth26(` ${renderSlider(sliderCells, marker)}`, width - 1));
50879
50957
  lines.push(...this.renderPricePanel(row, width));
50880
50958
  lines.push("");
@@ -51013,9 +51091,9 @@ var PresetEditor = class {
51013
51091
 
51014
51092
  // packages/fusion/src/sidekick-runtime.ts
51015
51093
  import { spawn as defaultSpawn2 } from "node:child_process";
51016
- import { mkdirSync as mkdirSync43, unlinkSync as unlinkSync12, writeFileSync as writeFileSync47 } from "node:fs";
51094
+ import { mkdirSync as mkdirSync44, unlinkSync as unlinkSync12, writeFileSync as writeFileSync48 } from "node:fs";
51017
51095
  import { tmpdir as tmpdir7 } from "node:os";
51018
- import { dirname as dirname49, join as join74 } from "node:path";
51096
+ import { dirname as dirname49, join as join75 } from "node:path";
51019
51097
  import { randomUUID as randomUUID11 } from "node:crypto";
51020
51098
  var emptyUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
51021
51099
  var SidekickRuntime = class {
@@ -51055,9 +51133,9 @@ var SidekickRuntime = class {
51055
51133
  this.promptPath = void 0;
51056
51134
  }
51057
51135
  spawn() {
51058
- mkdirSync43(dirname49(this.cfg.sessionFile), { recursive: true });
51059
- this.promptPath = join74(tmpdir7(), `unipi-fusion-${randomUUID11()}.txt`);
51060
- writeFileSync47(this.promptPath, this.cfg.systemPrompt, "utf8");
51136
+ mkdirSync44(dirname49(this.cfg.sessionFile), { recursive: true });
51137
+ this.promptPath = join75(tmpdir7(), `unipi-fusion-${randomUUID11()}.txt`);
51138
+ writeFileSync48(this.promptPath, this.cfg.systemPrompt, "utf8");
51061
51139
  const command = this.cfg.command ?? getPiSpawnCommand([
51062
51140
  "--mode",
51063
51141
  "rpc",
@@ -51476,7 +51554,7 @@ ${progressText(runtime, id)}`);
51476
51554
  var MODEL_COMMAND = `${UNIPI_PREFIX}model`;
51477
51555
  var PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
51478
51556
  function sidekickSessionPath(leadSessionId) {
51479
- return join75(homedir36(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
51557
+ return join76(homedir37(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
51480
51558
  }
51481
51559
  function registryOf(ctx) {
51482
51560
  const r = ctx.modelRegistry;
@@ -51487,17 +51565,17 @@ function findModel(reg, key) {
51487
51565
  if (modelBykey.size === 0 && reg) for (const m of reg.getAvailable()) modelBykey.set(modelKey(m), m);
51488
51566
  return modelBykey.get(key);
51489
51567
  }
51490
- function costOf(m) {
51491
- const cost = m?.cost;
51492
- return cost && typeof cost.input === "number" ? { input: cost.input, cachedInput: cost.cacheRead ?? 0, output: cost.output } : void 0;
51568
+ function costOf(m, override) {
51569
+ const cost = override ?? (m?.cost && typeof m.cost.input === "number" ? { input: m.cost.input, cachedInput: m.cost.cacheRead ?? 0, output: m.cost.output } : void 0);
51570
+ return cost && (cost.input > 0 || cost.cachedInput > 0 || cost.output > 0) ? cost : void 0;
51493
51571
  }
51494
- function toPickerModel(m, badge) {
51572
+ function toPickerModel(m, badge, override) {
51495
51573
  return {
51496
51574
  key: modelKey(m),
51497
51575
  name: m.name || m.id,
51498
51576
  provider: m.provider,
51499
51577
  badge,
51500
- cost: costOf(m),
51578
+ cost: costOf(m, override),
51501
51579
  reasoning: Boolean(m.reasoning)
51502
51580
  };
51503
51581
  }
@@ -51546,9 +51624,10 @@ function fusionExtension(pi) {
51546
51624
  function statusSavings(ctx) {
51547
51625
  if (active?.kind !== "fusion" || runtime === void 0) return void 0;
51548
51626
  const reg = registryOf(ctx);
51627
+ const preset2 = loadPreset(ctx.cwd ?? process.cwd()).preset;
51549
51628
  const lead = findModel(reg, active.lead);
51550
51629
  const side = findModel(reg, active.sidekick);
51551
- return estimateSavings(runtime.usage, costOf(lead), costOf(side)).savedUsd;
51630
+ return estimateSavings(runtime.usage, costOf(lead, preset2.prices[active.lead]), costOf(side, preset2.prices[active.sidekick])).savedUsd;
51552
51631
  }
51553
51632
  function publishStatus(ctx) {
51554
51633
  const reg = registryOf(ctx);
@@ -51589,10 +51668,14 @@ function fusionExtension(pi) {
51589
51668
  function savingsStats(ctx) {
51590
51669
  if (active?.kind !== "fusion" || runtime === void 0) return "Fusion is not active \u2014 pick a Fusion pair with /unipi:model.";
51591
51670
  const reg = registryOf(ctx);
51592
- const savings = estimateSavings(runtime.usage, costOf(findModel(reg, active.lead)), costOf(findModel(reg, active.sidekick)));
51671
+ const preset2 = loadPreset(ctx.cwd ?? process.cwd()).preset;
51672
+ const leadCost = costOf(findModel(reg, active.lead), preset2.prices[active.lead]);
51673
+ const sidekickCost = costOf(findModel(reg, active.sidekick), preset2.prices[active.sidekick]);
51674
+ const savings = estimateSavings(runtime.usage, leadCost, sidekickCost);
51675
+ const pricing = leadCost === void 0 && sidekickCost === void 0 ? '\nPricing unavailable from provider \u2014 set "prices" in ~/.unipi/config/fusion/preset.json to estimate savings.' : "";
51593
51676
  return `Sidekick tokens: in ${String(runtime.usage.input)} \xB7 out ${String(runtime.usage.output)} \xB7 cached ${String(runtime.usage.cacheRead)} \xB7 cache write ${String(runtime.usage.cacheWrite)}
51594
51677
  Sidekick cost: $${savings.sidekickUsd.toFixed(2)} \xB7 at lead prices: $${savings.atLeadUsd.toFixed(2)} \xB7 saved: $${savings.savedUsd.toFixed(2)}
51595
- Handoffs: ${String(runtime.reports.size)} \xB7 runtime alive: ${String(runtime.isAlive())} \xB7 busy: ${String(runtime.isBusy())}`;
51678
+ Handoffs: ${String(runtime.reports.size)} \xB7 runtime alive: ${String(runtime.isAlive())} \xB7 busy: ${String(runtime.isBusy())}${pricing}`;
51596
51679
  }
51597
51680
  registerFusionTools(pi, {
51598
51681
  getRuntime,
@@ -51664,7 +51747,7 @@ ${leadPolicy(identity(ctx))}` } : void 0);
51664
51747
  const cwd = ctx.cwd ?? process.cwd();
51665
51748
  const loaded = loadPreset(cwd);
51666
51749
  const preset2 = loaded.preset;
51667
- const models = reg.getAvailable().map((m) => toPickerModel(m, preset2.badges[modelKey(m)]));
51750
+ const models = reg.getAvailable().map((m) => toPickerModel(m, preset2.badges[modelKey(m)], preset2.prices[modelKey(m)]));
51668
51751
  if (models.length === 0) {
51669
51752
  ctx.ui.notify("No models available. Use /login to add a provider.", "warning");
51670
51753
  return;
@@ -51739,12 +51822,25 @@ ${String(result2.curation.lead.length)} lead \xB7 ${String(result2.curation.side
51739
51822
  );
51740
51823
  }
51741
51824
  });
51742
- pi.on("session_start", (_e, ctx) => {
51825
+ pi.on("session_start", async (_e, ctx) => {
51743
51826
  stopRuntime();
51744
51827
  nudged = false;
51745
51828
  modelBykey.clear();
51746
51829
  active = loadPreset(ctx.cwd ?? process.cwd()).preset.active;
51747
- if (active?.kind === "fusion" && ctx.model && modelKey(ctx.model) !== active.lead) active = void 0;
51830
+ if (active?.kind === "fusion" && (!ctx.model || modelKey(ctx.model) !== active.lead)) {
51831
+ const leadKey = active.lead;
51832
+ const lead = findModel(registryOf(ctx), leadKey);
51833
+ const restored = lead !== void 0 && await pi.setModel(lead);
51834
+ if (restored) {
51835
+ try {
51836
+ pi.setThinkingLevel(active.leadEffort ?? "medium");
51837
+ } catch {
51838
+ }
51839
+ } else {
51840
+ active = void 0;
51841
+ if (ctx.hasUI) ctx.ui.notify(`Fusion lead ${leadKey} unavailable \u2014 Fusion off`, "warning");
51842
+ }
51843
+ }
51748
51844
  publishStatus(ctx);
51749
51845
  if (ctx.hasUI) ctx.ui.addAutocompleteProvider(createModelBoostProvider);
51750
51846
  });
@@ -51756,6 +51852,8 @@ ${String(result2.curation.lead.length)} lead \xB7 ${String(result2.curation.side
51756
51852
  if (active?.kind === "fusion" && modelKey(event.model) !== active.lead) {
51757
51853
  stopRuntime();
51758
51854
  active = { kind: "single", model: modelKey(event.model) };
51855
+ const loaded = loadPreset(ctx.cwd ?? process.cwd());
51856
+ saveRuntimeState(globalPresetPath(), { effort: loaded.preset.effort, recent: loaded.preset.recent, active });
51759
51857
  publishStatus(ctx);
51760
51858
  }
51761
51859
  });
@@ -51919,9 +52017,9 @@ var PrefixIntegrityTracker = class {
51919
52017
  };
51920
52018
 
51921
52019
  // packages/trajectory/src/telemetry.ts
51922
- import { appendFileSync, closeSync as closeSync4, mkdirSync as mkdirSync44, openSync as openSync4, readSync as readSync3, statSync as statSync18 } from "node:fs";
51923
- import { homedir as homedir37 } from "node:os";
51924
- import { join as join76 } from "node:path";
52020
+ import { appendFileSync, closeSync as closeSync4, mkdirSync as mkdirSync45, openSync as openSync4, readSync as readSync3, statSync as statSync18 } from "node:fs";
52021
+ import { homedir as homedir38 } from "node:os";
52022
+ import { join as join77 } from "node:path";
51925
52023
  var SECRET_KEY = /authorization|api[-_]?key|token|cookie|secret|password|credential/i;
51926
52024
  var SECRET_VALUE = /^(?:bearer\s+|sk-[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{12,}|AIza[a-z0-9_-]{20,})/i;
51927
52025
  var MAX_STRING = 2e5;
@@ -51985,9 +52083,9 @@ function redactTelemetry(value, seen = /* @__PURE__ */ new WeakSet()) {
51985
52083
  var TelemetrySidecar = class {
51986
52084
  file;
51987
52085
  state;
51988
- constructor(sessionId, root = join76(homedir37(), ".unipi", "trajectory")) {
51989
- mkdirSync44(root, { recursive: true, mode: 448 });
51990
- this.file = join76(root, `${sessionId.replace(/[^a-zA-Z0-9._-]/g, "_")}.jsonl`);
52086
+ constructor(sessionId, root = join77(homedir38(), ".unipi", "trajectory")) {
52087
+ mkdirSync45(root, { recursive: true, mode: 448 });
52088
+ this.file = join77(root, `${sessionId.replace(/[^a-zA-Z0-9._-]/g, "_")}.jsonl`);
51991
52089
  this.state = stateFor(this.file);
51992
52090
  }
51993
52091
  append(event) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/updater",
3
- "version": "2.17.0",
3
+ "version": "2.17.2",
4
4
  "description": "Auto-updater, changelog browser, and readme browser for Unipi — checks npm registry, renders CHANGELOG.md and README.md files in TUI overlays",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -47,11 +47,14 @@ const SECTION_HEADER_RE = /^### (.+)$/;
47
47
  */
48
48
  export function parseChangelog(filePath: string): ChangelogEntry[] {
49
49
  if (!existsSync(filePath)) return [];
50
+ return parseChangelogContent(readFileSync(filePath, "utf-8"));
51
+ }
50
52
 
51
- const content = readFileSync(filePath, "utf-8").trim();
52
- if (!content) return [];
53
+ export function parseChangelogContent(content: string): ChangelogEntry[] {
54
+ const trimmed = content.trim();
55
+ if (!trimmed) return [];
53
56
 
54
- const lines = content.split("\n");
57
+ const lines = trimmed.split("\n");
55
58
  const entries: ChangelogEntry[] = [];
56
59
 
57
60
  let currentEntry: ChangelogEntry | null = null;
@@ -143,7 +146,7 @@ export function getNewerVersions(
143
146
  const result: ChangelogEntry[] = [];
144
147
  for (const entry of entries) {
145
148
  if (entry.version === "Unreleased") {
146
- result.push(entry);
149
+ if (Object.keys(entry.sections).length > 0) result.push(entry);
147
150
  continue;
148
151
  }
149
152
  // Compare rather than test for equality. Stopping only on an exact match
@@ -23,6 +23,7 @@ import { loadConfig } from "./settings.js";
23
23
  import { checkForUpdates } from "./checker.js";
24
24
  import { isVersionSkipped } from "./cache.js";
25
25
  import { renderUpdateOverlay } from "./tui/update-overlay.js";
26
+ import { loadUpdateChangelog } from "./remote-changelog.js";
26
27
 
27
28
  /** Package version */
28
29
  const VERSION = getPackageVersion(new URL("..", import.meta.url).pathname);
@@ -142,8 +143,9 @@ export default function updaterExtension(pi: ExtensionAPI): void {
142
143
 
143
144
  // Show update overlay if UI is available
144
145
  if (ctx.hasUI) {
146
+ const entries = await loadUpdateChangelog(result.currentVersion, result.latestVersion);
145
147
  const updateResult = await ctx.ui.custom(
146
- renderUpdateOverlay(result),
148
+ renderUpdateOverlay(result, entries),
147
149
  {
148
150
  overlay: true,
149
151
  overlayOptions: {
@@ -0,0 +1,61 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { UPDATER_DIRS } from "@pi-unipi/core";
5
+ import type { ChangelogEntry } from "../types.js";
6
+ import { getNewerVersions, parseChangelogContent, parseChangelog, resolveChangelogPath } from "./changelog.js";
7
+
8
+ export const CHANGELOG_RAW_BASE = "https://raw.githubusercontent.com/Neuron-Mr-White/unipi";
9
+
10
+ export interface RemoteChangelogOptions {
11
+ fetchImpl?: typeof fetch;
12
+ cacheDir?: string;
13
+ timeoutMs?: number;
14
+ }
15
+
16
+ function cacheDirectory(opts?: RemoteChangelogOptions): string {
17
+ return (opts?.cacheDir ?? UPDATER_DIRS.CACHE).replace("~", homedir());
18
+ }
19
+
20
+ export async function fetchRemoteChangelog(version: string, opts: RemoteChangelogOptions = {}): Promise<string | null> {
21
+ const cacheDir = cacheDirectory(opts);
22
+ const cachePath = join(cacheDir, `changelog-${version}.md`);
23
+ try {
24
+ if (existsSync(cachePath)) return readFileSync(cachePath, "utf8");
25
+ } catch {
26
+ // Continue with the network request.
27
+ }
28
+
29
+ const fetchImpl = opts.fetchImpl ?? fetch;
30
+ const signal = AbortSignal.timeout(opts.timeoutMs ?? 5000);
31
+ try {
32
+ let response = await fetchImpl(`${CHANGELOG_RAW_BASE}/v${version}/CHANGELOG.md`, { signal });
33
+ // The release tag is immutable, so its changelog is safe to cache forever.
34
+ // The `main` fallback (tag not pushed yet) is not: never cache it.
35
+ const cacheable = response.ok;
36
+ if (response.status === 404) {
37
+ response = await fetchImpl(`${CHANGELOG_RAW_BASE}/main/CHANGELOG.md`, { signal });
38
+ }
39
+ if (!response.ok) return null;
40
+ const content = await response.text();
41
+ if (cacheable) {
42
+ mkdirSync(cacheDir, { recursive: true });
43
+ writeFileSync(cachePath, content, "utf8");
44
+ }
45
+ return content;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export async function loadUpdateChangelog(
52
+ currentVersion: string,
53
+ latestVersion: string,
54
+ opts?: RemoteChangelogOptions,
55
+ ): Promise<ChangelogEntry[]> {
56
+ const remote = await fetchRemoteChangelog(latestVersion, opts);
57
+ const entries = remote === null ? [] : parseChangelogContent(remote);
58
+ const newer = remote === null ? [] : getNewerVersions(entries, currentVersion);
59
+ if (newer.length > 0) return newer;
60
+ return getNewerVersions(parseChangelog(resolveChangelogPath()), currentVersion);
61
+ }
@@ -40,7 +40,7 @@ interface UpdateState {
40
40
  /**
41
41
  * Render the update available overlay.
42
42
  */
43
- export function renderUpdateOverlay(checkResult: UpdateCheckResult) {
43
+ export function renderUpdateOverlay(checkResult: UpdateCheckResult, providedNewerVersions?: ChangelogEntry[]) {
44
44
  return (
45
45
  tui: import("@earendil-works/pi-tui").TUI,
46
46
  theme: Theme,
@@ -49,14 +49,16 @@ export function renderUpdateOverlay(checkResult: UpdateCheckResult) {
49
49
  ) => {
50
50
  const config = loadConfig();
51
51
 
52
- // Load changelog for newer versions
53
- let newerVersions: ChangelogEntry[] = [];
54
- const changelogPath = resolveChangelogPath();
55
- try {
56
- const entries = parseChangelog(changelogPath);
57
- newerVersions = getNewerVersions(entries, checkResult.currentVersion);
58
- } catch (_err) {
59
- // No changelog
52
+ // Load changelog for newer versions unless the caller already fetched it.
53
+ let newerVersions: ChangelogEntry[] = providedNewerVersions ?? [];
54
+ if (providedNewerVersions === undefined) {
55
+ const changelogPath = resolveChangelogPath();
56
+ try {
57
+ const entries = parseChangelog(changelogPath);
58
+ newerVersions = getNewerVersions(entries, checkResult.currentVersion);
59
+ } catch (_err) {
60
+ // No changelog
61
+ }
60
62
  }
61
63
 
62
64
  // Build content lines from changelog using markdown renderer
@@ -74,7 +76,7 @@ export function renderUpdateOverlay(checkResult: UpdateCheckResult) {
74
76
  contentLines.push("");
75
77
  }
76
78
  if (contentLines.length === 0) {
77
- contentLines.push(` ${theme.fg("muted", "No changelog available for this update.")}`);
79
+ contentLines.push(` ${theme.fg("muted", `No changelog available for ${checkResult.latestVersion} (offline?).`)}`);
78
80
  }
79
81
 
80
82
  const state: UpdateState = {