feat: show update notification in TUI and footer (#1204)

This commit is contained in:
cthomas
2026-02-27 15:03:47 -08:00
committed by GitHub
parent bb5d7d0a39
commit 841e2332f3
5 changed files with 100 additions and 8 deletions

View File

@@ -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<StaticItem[]>([]);
// Show in-transcript notification when auto-update applied a significant new version
const [footerUpdateText, setFooterUpdateText] = useState<string | null>(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<Set<string>>(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}
/>
</Box>

View File

@@ -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"})
</Text>
</Text>
) : footerNotification ? (
<Text color={colors.status.processingShimmer}>
{footerNotification}
</Text>
) : (
<Text dimColor>Press / for commands</Text>
)}
@@ -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}
/>
)}
</Box>
@@ -1530,6 +1539,7 @@ export function Input({
statusLineText,
statusLineRight,
statusLinePadding,
footerNotification,
promptChar,
promptVisualWidth,
suppressDividers,

View File

@@ -365,7 +365,7 @@ async function main(): Promise<void> {
// 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<void> {
// Release notes to display (checked once on mount)
const [releaseNotes, setReleaseNotes] = useState<string | null>(null);
// Update notification: set when auto-update applied a significant new version
const [updateNotification, setUpdateNotification] = useState<string | null>(
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<void> {
showCompactions: settings.showCompactions,
agentProvenance,
releaseNotes,
updateNotification,
sessionContextReminderEnabled: !noSystemInfoReminderFlag,
});
}

View File

@@ -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<UpdateNotification | undefined> {
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);
}

View File

@@ -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}`,
};
}