diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index b3df9eb4d..1cd75a47f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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(); } diff --git a/packages/coding-agent/src/package-manager-cli.ts b/packages/coding-agent/src/package-manager-cli.ts index a55a37509..8fd5d6d45 100644 --- a/packages/coding-agent/src/package-manager-cli.ts +++ b/packages/coding-agent/src/package-manager-cli.ts @@ -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 { @@ -307,7 +346,7 @@ async function getSelfUpdatePlan(force: boolean): Promise { 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 { process.exitCode = 1; return true; } + if (selfUpdatePlan.note) { + printSelfUpdateNote(selfUpdatePlan.note); + } try { if (installMethod === "npm") { prepareWindowsNpmSelfUpdate(); diff --git a/packages/coding-agent/src/utils/version-check.ts b/packages/coding-agent/src/utils/version-check.ts index ec9694693..27f3390c1 100644 --- a/packages/coding-agent/src/utils/version-check.ts +++ b/packages/coding-agent/src/utils/version-check.ts @@ -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 { +export async function checkForNewPiVersion(currentVersion: string): Promise { 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 { diff --git a/packages/coding-agent/test/version-check.test.ts b/packages/coding-agent/test/version-check.test.ts index c871718f5..f05bfd2e8 100644 --- a/packages/coding-agent/test/version-check.test.ts +++ b/packages/coding-agent/test/version-check.test.ts @@ -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 () => {