@pi-unipi/unipi 2.17.1 → 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,6 +6,13 @@ 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.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
+
9
16
  ## [2.17.1] — 2026-09-15
10
17
 
11
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.17.1",
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,7 +90,7 @@
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
95
  "@pi-unipi/fusion": "2.17.1",
96
96
  "@pi-unipi/info-screen": "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;
@@ -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;
@@ -50326,11 +50379,11 @@ function emptyPreset() {
50326
50379
  prices: {}
50327
50380
  };
50328
50381
  }
50329
- function globalPresetPath(home = homedir35()) {
50330
- return join73(home, ".unipi", "config", "fusion", "preset.json");
50382
+ function globalPresetPath(home = homedir36()) {
50383
+ return join74(home, ".unipi", "config", "fusion", "preset.json");
50331
50384
  }
50332
50385
  function projectPresetPath(cwd) {
50333
- return join73(cwd, ".unipi", "fusion-preset.json");
50386
+ return join74(cwd, ".unipi", "fusion-preset.json");
50334
50387
  }
50335
50388
  function modelKey(model) {
50336
50389
  return `${model.provider}/${model.id}`;
@@ -50424,13 +50477,13 @@ function mergePresets(base, over) {
50424
50477
  }
50425
50478
  function readJson4(path41) {
50426
50479
  try {
50427
- if (!existsSync63(path41)) return void 0;
50428
- return JSON.parse(readFileSync54(path41, "utf8"));
50480
+ if (!existsSync64(path41)) return void 0;
50481
+ return JSON.parse(readFileSync55(path41, "utf8"));
50429
50482
  } catch {
50430
50483
  return void 0;
50431
50484
  }
50432
50485
  }
50433
- function loadPreset(cwd, home = homedir35()) {
50486
+ function loadPreset(cwd, home = homedir36()) {
50434
50487
  const globalPath = globalPresetPath(home);
50435
50488
  const projectPath = projectPresetPath(cwd);
50436
50489
  const globalRaw = readJson4(globalPath);
@@ -50441,9 +50494,9 @@ function loadPreset(cwd, home = homedir35()) {
50441
50494
  return { preset: preset2, globalPath, projectPath, hasProjectLayer };
50442
50495
  }
50443
50496
  function writeJsonAtomic2(path41, value) {
50444
- mkdirSync42(dirname48(path41), { recursive: true });
50497
+ mkdirSync43(dirname48(path41), { recursive: true });
50445
50498
  const tmp = `${path41}.${String(process.pid)}.tmp`;
50446
- writeFileSync46(tmp, `${JSON.stringify(value, null, 2)}
50499
+ writeFileSync47(tmp, `${JSON.stringify(value, null, 2)}
50447
50500
  `, "utf8");
50448
50501
  renameSync15(tmp, path41);
50449
50502
  }
@@ -51038,9 +51091,9 @@ var PresetEditor = class {
51038
51091
 
51039
51092
  // packages/fusion/src/sidekick-runtime.ts
51040
51093
  import { spawn as defaultSpawn2 } from "node:child_process";
51041
- 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";
51042
51095
  import { tmpdir as tmpdir7 } from "node:os";
51043
- import { dirname as dirname49, join as join74 } from "node:path";
51096
+ import { dirname as dirname49, join as join75 } from "node:path";
51044
51097
  import { randomUUID as randomUUID11 } from "node:crypto";
51045
51098
  var emptyUsage = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 });
51046
51099
  var SidekickRuntime = class {
@@ -51080,9 +51133,9 @@ var SidekickRuntime = class {
51080
51133
  this.promptPath = void 0;
51081
51134
  }
51082
51135
  spawn() {
51083
- mkdirSync43(dirname49(this.cfg.sessionFile), { recursive: true });
51084
- this.promptPath = join74(tmpdir7(), `unipi-fusion-${randomUUID11()}.txt`);
51085
- 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");
51086
51139
  const command = this.cfg.command ?? getPiSpawnCommand([
51087
51140
  "--mode",
51088
51141
  "rpc",
@@ -51501,7 +51554,7 @@ ${progressText(runtime, id)}`);
51501
51554
  var MODEL_COMMAND = `${UNIPI_PREFIX}model`;
51502
51555
  var PRESET_COMMAND = `${UNIPI_PREFIX}fusion-preset`;
51503
51556
  function sidekickSessionPath(leadSessionId) {
51504
- return join75(homedir36(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
51557
+ return join76(homedir37(), ".unipi", "state", "fusion", "sidekick", `${leadSessionId ?? "default"}.jsonl`);
51505
51558
  }
51506
51559
  function registryOf(ctx) {
51507
51560
  const r = ctx.modelRegistry;
@@ -51964,9 +52017,9 @@ var PrefixIntegrityTracker = class {
51964
52017
  };
51965
52018
 
51966
52019
  // packages/trajectory/src/telemetry.ts
51967
- import { appendFileSync, closeSync as closeSync4, mkdirSync as mkdirSync44, openSync as openSync4, readSync as readSync3, statSync as statSync18 } from "node:fs";
51968
- import { homedir as homedir37 } from "node:os";
51969
- 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";
51970
52023
  var SECRET_KEY = /authorization|api[-_]?key|token|cookie|secret|password|credential/i;
51971
52024
  var SECRET_VALUE = /^(?:bearer\s+|sk-[a-z0-9_-]{12,}|gh[pousr]_[a-z0-9]{12,}|AIza[a-z0-9_-]{20,})/i;
51972
52025
  var MAX_STRING = 2e5;
@@ -52030,9 +52083,9 @@ function redactTelemetry(value, seen = /* @__PURE__ */ new WeakSet()) {
52030
52083
  var TelemetrySidecar = class {
52031
52084
  file;
52032
52085
  state;
52033
- constructor(sessionId, root = join76(homedir37(), ".unipi", "trajectory")) {
52034
- mkdirSync44(root, { recursive: true, mode: 448 });
52035
- 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`);
52036
52089
  this.state = stateFor(this.file);
52037
52090
  }
52038
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 = {