feat(coding-agent): show update notes (#4724)

This commit is contained in:
Armin Ronacher
2026-05-19 12:08:13 +02:00
committed by GitHub
Unverified
parent 2787b601d7
commit f4f0ac7ada
4 changed files with 97 additions and 23 deletions
@@ -91,7 +91,7 @@ import { getCwdRelativePath } from "../../utils/paths.js";
import { getPiUserAgent } from "../../utils/pi-user-agent.js";
import { killTrackedDetachedChildren } from "../../utils/shell.js";
import { ensureTool } from "../../utils/tools-manager.js";
import { checkForNewPiVersion } from "../../utils/version-check.js";
import { checkForNewPiVersion, type LatestPiRelease } from "../../utils/version-check.js";
import { ArminComponent } from "./components/armin.js";
import { AssistantMessageComponent } from "./components/assistant-message.js";
import { BashExecutionComponent } from "./components/bash-execution.js";
@@ -711,9 +711,9 @@ export class InteractiveMode {
await this.init();
// Start version check asynchronously
checkForNewPiVersion(this.version).then((newVersion) => {
if (newVersion) {
this.showNewVersionNotification(newVersion);
checkForNewPiVersion(this.version).then((newRelease) => {
if (newRelease) {
this.showNewVersionNotification(newRelease);
}
});
@@ -3575,24 +3575,31 @@ export class InteractiveMode {
this.ui.requestRender();
}
showNewVersionNotification(newVersion: string): void {
showNewVersionNotification(release: LatestPiRelease): void {
const action = theme.fg("accent", `${APP_NAME} update`);
const updateInstruction = theme.fg("muted", `New version ${newVersion} is available. Run `) + action;
const changelogUrl = "https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/CHANGELOG.md";
const updateInstruction = theme.fg("muted", `New version ${release.version} is available. Run `) + action;
const changelogUrl = "https://pi.dev/changelog";
const changelogLink = getCapabilities().hyperlinks
? hyperlink(theme.fg("accent", "open changelog"), changelogUrl)
: theme.fg("accent", changelogUrl);
const changelogLine = theme.fg("muted", "Changelog: ") + changelogLink;
const note = release.note?.trim();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.chatContainer.addChild(
new Text(
`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}\n${changelogLine}`,
1,
0,
),
new Text(`${theme.bold(theme.fg("warning", "Update Available"))}\n${updateInstruction}`, 1, 0),
);
if (note) {
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(
new Markdown(note, 1, 0, this.getMarkdownThemeWithSettings(), {
color: (text) => theme.fg("muted", text),
}),
);
this.chatContainer.addChild(new Spacer(1));
}
this.chatContainer.addChild(new Text(changelogLine, 1, 0));
this.chatContainer.addChild(new DynamicBorder((text) => theme.fg("warning", text)));
this.ui.requestRender();
}
@@ -1,3 +1,4 @@
import { Markdown, type MarkdownTheme } from "@earendil-works/pi-tui";
import chalk from "chalk";
import { selectConfig } from "./cli/config-selector.js";
import {
@@ -24,6 +25,23 @@ export type PackageCommand = "install" | "remove" | "update" | "list";
type UpdateTarget = { type: "all" } | { type: "self" } | { type: "extensions"; source?: string };
const SELF_UPDATE_NOTE_MARKDOWN_THEME: MarkdownTheme = {
heading: (text) => chalk.bold(chalk.yellow(text)),
link: (text) => chalk.cyan(text),
linkUrl: (text) => chalk.dim(text),
code: (text) => chalk.yellow(text),
codeBlock: (text) => chalk.dim(text),
codeBlockBorder: (text) => chalk.dim(text),
quote: (text) => chalk.dim(text),
quoteBorder: (text) => chalk.dim(text),
hr: (text) => chalk.dim(text),
listBullet: (text) => chalk.yellow(text),
bold: (text) => chalk.bold(text),
italic: (text) => chalk.italic(text),
strikethrough: (text) => chalk.strikethrough(text),
underline: (text) => chalk.underline(text),
};
interface PackageCommandOptions {
command: PackageCommand;
source?: string;
@@ -293,9 +311,30 @@ function printSelfUpdateFallback(command: SelfUpdateCommand): void {
console.error(chalk.dim(`If this keeps failing, run this command yourself: ${command.display}`));
}
function printSelfUpdateNote(note: string): void {
const trimmedNote = note.trim();
if (!trimmedNote) {
return;
}
console.log();
console.log(chalk.bold(chalk.yellow("Update note")));
try {
const width = Math.max(20, process.stdout.columns ?? 80);
const renderedLines = new Markdown(trimmedNote, 0, 0, SELF_UPDATE_NOTE_MARKDOWN_THEME)
.render(width)
.map((line) => line.trimEnd());
console.log(renderedLines.join("\n"));
} catch {
console.log(trimmedNote);
}
console.log();
}
interface SelfUpdatePlan {
packageName: string;
shouldRun: boolean;
note?: string;
}
async function getSelfUpdatePlan(force: boolean): Promise<SelfUpdatePlan> {
@@ -307,7 +346,7 @@ async function getSelfUpdatePlan(force: boolean): Promise<SelfUpdatePlan> {
const latestRelease = await getLatestPiRelease(VERSION);
const packageName = latestRelease?.packageName ?? PACKAGE_NAME;
if (!latestRelease || packageName !== PACKAGE_NAME || isNewerPackageVersion(latestRelease.version, VERSION)) {
return { packageName, shouldRun: true };
return { packageName, shouldRun: true, ...(latestRelease?.note ? { note: latestRelease.note } : {}) };
}
} catch {
return { packageName: PACKAGE_NAME, shouldRun: true };
@@ -522,6 +561,9 @@ export async function handlePackageCommand(args: string[]): Promise<boolean> {
process.exitCode = 1;
return true;
}
if (selfUpdatePlan.note) {
printSelfUpdateNote(selfUpdatePlan.note);
}
try {
if (installMethod === "npm") {
prepareWindowsNpmSelfUpdate();
@@ -6,6 +6,7 @@ const DEFAULT_VERSION_CHECK_TIMEOUT_MS = 10000;
export interface LatestPiRelease {
version: string;
packageName?: string;
note?: string;
}
interface ParsedVersion {
@@ -67,13 +68,22 @@ export async function getLatestPiRelease(
});
if (!response.ok) return undefined;
const data = (await response.json()) as { packageName?: unknown; version?: unknown };
const data = (await response.json()) as {
packageName?: unknown;
version?: unknown;
note?: unknown;
};
if (typeof data.version !== "string" || !data.version.trim()) {
return undefined;
}
const packageName =
typeof data.packageName === "string" && data.packageName.trim() ? data.packageName.trim() : undefined;
return { version: data.version.trim(), packageName };
const note = typeof data.note === "string" && data.note.trim() ? data.note.trim() : undefined;
return {
version: data.version.trim(),
packageName,
...(note ? { note } : {}),
};
}
export async function getLatestPiVersion(
@@ -83,11 +93,11 @@ export async function getLatestPiVersion(
return (await getLatestPiRelease(currentVersion, options))?.version;
}
export async function checkForNewPiVersion(currentVersion: string): Promise<string | undefined> {
export async function checkForNewPiVersion(currentVersion: string): Promise<LatestPiRelease | undefined> {
try {
const latestVersion = await getLatestPiVersion(currentVersion);
if (latestVersion && isNewerPackageVersion(latestVersion, currentVersion)) {
return latestVersion;
const latestRelease = await getLatestPiRelease(currentVersion);
if (latestRelease && isNewerPackageVersion(latestRelease.version, currentVersion)) {
return latestRelease;
}
return undefined;
} catch {
@@ -38,7 +38,7 @@ describe("version checks", () => {
vi.stubGlobal("fetch", fetchMock);
await expect(checkForNewPiVersion("1.2.3")).resolves.toBeUndefined();
await expect(checkForNewPiVersion("1.2.2")).resolves.toBe("1.2.3");
await expect(checkForNewPiVersion("1.2.2")).resolves.toEqual({ version: "1.2.3" });
});
it("uses the pi.dev version check api with a pi user agent", async () => {
@@ -57,11 +57,26 @@ describe("version checks", () => {
);
});
it("returns the active package name from the version check api", async () => {
const fetchMock = vi.fn(async () => Response.json({ packageName: "@new-scope/pi", version: "1.2.4" }));
it("returns the active package metadata from the version check api", async () => {
const fetchMock = vi.fn(async () =>
Response.json({
packageName: "@new-scope/pi",
version: "1.2.4",
}),
);
vi.stubGlobal("fetch", fetchMock);
await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ packageName: "@new-scope/pi", version: "1.2.4" });
await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({
packageName: "@new-scope/pi",
version: "1.2.4",
});
});
it("returns update notes from the version check api", async () => {
const fetchMock = vi.fn(async () => Response.json({ note: " **Read this** ", version: "1.2.4" }));
vi.stubGlobal("fetch", fetchMock);
await expect(getLatestPiRelease("1.2.3")).resolves.toEqual({ note: "**Read this**", version: "1.2.4" });
});
it("skips api calls when version checks are disabled", async () => {