From 841e2332f3c8a1e5d234656806fa360abe03df5f Mon Sep 17 00:00:00 2001 From: cthomas Date: Fri, 27 Feb 2026 15:03:47 -0800 Subject: [PATCH] feat: show update notification in TUI and footer (#1204) --- src/cli/App.tsx | 28 ++++++++++++++++++++++++++++ src/cli/components/InputRich.tsx | 10 ++++++++++ src/index.ts | 17 ++++++++++++++++- src/startup-auto-update.ts | 26 ++++++++++++++++++++------ src/updater/auto-update.ts | 27 ++++++++++++++++++++++++++- 5 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index afaf0ed..ea3eb71 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -967,6 +967,7 @@ export default function App({ showCompactions = false, agentProvenance = null, releaseNotes = null, + updateNotification = null, sessionContextReminderEnabled = true, }: { agentId: string; @@ -988,6 +989,7 @@ export default function App({ showCompactions?: boolean; agentProvenance?: AgentProvenance | null; releaseNotes?: string | null; // Markdown release notes to display above header + updateNotification?: string | null; // Latest version when a significant auto-update was applied sessionContextReminderEnabled?: boolean; }) { // Warm the model-access cache in the background so /model is fast on first open. @@ -1707,6 +1709,31 @@ export default function App({ // Static items (things that are done rendering and can be frozen) const [staticItems, setStaticItems] = useState([]); + // Show in-transcript notification when auto-update applied a significant new version + const [footerUpdateText, setFooterUpdateText] = useState(null); + useEffect(() => { + if (!updateNotification) return; + setStaticItems((prev) => { + if (prev.some((item) => item.id === "update-notification")) return prev; + return [ + ...prev, + { + kind: "status" as const, + id: "update-notification", + lines: [ + `A new version of Letta Code is available (**${updateNotification}**). Restart to update!`, + ], + }, + ]; + }); + // Also show briefly in the footer placeholder area + setFooterUpdateText( + `New version available (${updateNotification}). Restart to update!`, + ); + const timer = setTimeout(() => setFooterUpdateText(null), 8000); + return () => clearTimeout(timer); + }, [updateNotification]); + // Track committed ids to avoid duplicates const emittedIdsRef = useRef>(new Set()); @@ -12743,6 +12770,7 @@ If using apply_patch, use this exact relative patch path: ${applyPatchRelativePa statusLineRight={statusLine.rightText || undefined} statusLinePadding={statusLine.padding || 0} statusLinePrompt={statusLine.prompt} + footerNotification={footerUpdateText} /> diff --git a/src/cli/components/InputRich.tsx b/src/cli/components/InputRich.tsx index 616b1b8..16ff968 100644 --- a/src/cli/components/InputRich.tsx +++ b/src/cli/components/InputRich.tsx @@ -234,6 +234,7 @@ const InputFooter = memo(function InputFooter({ statusLineText, statusLineRight, statusLinePadding, + footerNotification, }: { ctrlCPressed: boolean; escapePressed: boolean; @@ -252,6 +253,7 @@ const InputFooter = memo(function InputFooter({ statusLineText?: string; statusLineRight?: string; statusLinePadding?: number; + footerNotification?: string | null; }) { const hideFooterContent = hideFooter; const maxAgentChars = Math.max(10, Math.floor(rightColumnWidth * 0.45)); @@ -328,6 +330,10 @@ const InputFooter = memo(function InputFooter({ (shift+tab to {showExitHint ? "exit" : "cycle"}) + ) : footerNotification ? ( + + {footerNotification} + ) : ( Press / for commands )} @@ -621,6 +627,7 @@ export function Input({ statusLinePadding = 0, statusLinePrompt, onCycleReasoningEffort, + footerNotification, }: { visible?: boolean; streaming: boolean; @@ -662,6 +669,7 @@ export function Input({ statusLinePadding?: number; statusLinePrompt?: string; onCycleReasoningEffort?: () => void; + footerNotification?: string | null; }) { const [value, setValue] = useState(""); const [escapePressed, setEscapePressed] = useState(false); @@ -1484,6 +1492,7 @@ export function Input({ statusLineText={statusLineText} statusLineRight={statusLineRight} statusLinePadding={statusLinePadding} + footerNotification={footerNotification} /> )} @@ -1530,6 +1539,7 @@ export function Input({ statusLineText, statusLineRight, statusLinePadding, + footerNotification, promptChar, promptVisualWidth, suppressDividers, diff --git a/src/index.ts b/src/index.ts index a8a0d7a..92d7e7f 100755 --- a/src/index.ts +++ b/src/index.ts @@ -365,7 +365,7 @@ async function main(): Promise { // Check for updates on startup (non-blocking) const { checkAndAutoUpdate } = await import("./updater/auto-update"); - startStartupAutoUpdateCheck(checkAndAutoUpdate); + const autoUpdatePromise = startStartupAutoUpdateCheck(checkAndAutoUpdate); // Clean up old overflow files (non-blocking, 24h retention) const { cleanupOldOverflowFiles } = await import("./tools/impl/overflow"); @@ -1032,6 +1032,20 @@ async function main(): Promise { // Release notes to display (checked once on mount) const [releaseNotes, setReleaseNotes] = useState(null); + // Update notification: set when auto-update applied a significant new version + const [updateNotification, setUpdateNotification] = useState( + null, + ); + useEffect(() => { + autoUpdatePromise + .then((result) => { + if (result?.latestVersion) { + setUpdateNotification(result.latestVersion); + } + }) + .catch(() => {}); + }, []); + // Auto-install Shift+Enter keybinding for VS Code/Cursor/Windsurf (silent, no prompt) useEffect(() => { async function autoInstallKeybinding() { @@ -2067,6 +2081,7 @@ async function main(): Promise { showCompactions: settings.showCompactions, agentProvenance, releaseNotes, + updateNotification, sessionContextReminderEnabled: !noSystemInfoReminderFlag, }); } diff --git a/src/startup-auto-update.ts b/src/startup-auto-update.ts index 067548b..b7602e3 100644 --- a/src/startup-auto-update.ts +++ b/src/startup-auto-update.ts @@ -1,11 +1,22 @@ +export interface UpdateNotification { + latestVersion: string; +} + export function startStartupAutoUpdateCheck( - checkAndAutoUpdate: () => Promise<{ enotemptyFailed?: boolean } | undefined>, + checkAndAutoUpdate: () => Promise< + | { + enotemptyFailed?: boolean; + latestVersion?: string; + updateApplied?: boolean; + } + | undefined + >, logError: ( message?: unknown, ...optionalParams: unknown[] ) => void = console.error, -): void { - checkAndAutoUpdate() +): Promise { + return checkAndAutoUpdate() .then((result) => { // Surface ENOTEMPTY failures so users know how to fix if (result?.enotemptyFailed) { @@ -14,8 +25,11 @@ export function startStartupAutoUpdateCheck( "Fix: rm -rf $(npm prefix -g)/lib/node_modules/@letta-ai/letta-code && npm i -g @letta-ai/letta-code\n", ); } + // Return notification payload for the TUI to consume + if (result?.updateApplied && result.latestVersion) { + return { latestVersion: result.latestVersion }; + } + return undefined; }) - .catch(() => { - // Silently ignore other update failures (network timeouts, etc.) - }); + .catch(() => undefined); } diff --git a/src/updater/auto-update.ts b/src/updater/auto-update.ts index 8d9e4f5..75862f6 100644 --- a/src/updater/auto-update.ts +++ b/src/updater/auto-update.ts @@ -357,6 +357,23 @@ async function performUpdate(): Promise<{ export interface AutoUpdateResult { /** Whether an ENOTEMPTY error persisted after cleanup and retry */ enotemptyFailed?: boolean; + /** Latest version available (set when a significant update was applied) */ + latestVersion?: string; + /** True when the binary was updated and the user should restart */ + updateApplied?: boolean; +} + +/** + * Returns true when `latest` is at least one minor version ahead of `current`. + * Used to gate the in-app "restart to update" notification — patch-only bumps + * are applied silently without interrupting the user. + */ +function isSignificantUpdate(current: string, latest: string): boolean { + const [cMajor = 0, cMinor = 0] = current.split(".").map(Number); + const [lMajor = 0, lMinor = 0] = latest.split(".").map(Number); + if (lMajor > cMajor) return true; + if (lMajor === cMajor && lMinor > cMinor) return true; + return false; } export async function checkAndAutoUpdate(): Promise< @@ -384,6 +401,13 @@ export async function checkAndAutoUpdate(): Promise< if (updateResult.enotemptyFailed) { return { enotemptyFailed: true }; } + if ( + updateResult.success && + result.latestVersion && + isSignificantUpdate(result.currentVersion, result.latestVersion) + ) { + return { updateApplied: true, latestVersion: result.latestVersion }; + } } } @@ -427,8 +451,9 @@ export async function manualUpdate(): Promise<{ }; } + const installCmd = buildInstallCommand(detectPackageManager()); return { success: false, - message: `Update failed: ${updateResult.error}`, + message: `Update failed: ${updateResult.error}\n\nTo update manually: ${installCmd}`, }; }