import { useKeyboard, useTerminalDimensions } from '@opentui/react'; import { useEffect, useRef, useState } from 'react'; import { sanitizeTerminalText } from '../../utils/terminal'; import { getInstallCommand, markRemindLater, markVersionSkipped, runUpdateInstall } from '../../utils/update-check'; import { computeOverlayRect } from '../layout'; import { isTuiShutdownError, useTuiLifecycle } from '../lifecycle'; import { useAppDispatch, useAppState } from '../state/context'; import type { UpdateInfo } from '../state/types'; import { terminalCellWidth, truncateTerminalText } from '../text'; import { colors } from '../theme'; export interface UpdateOverlayProps { /** Injectable so installation behavior can be verified without spawning a process. */ install?: (packageName: string, version: string, signal?: AbortSignal) => Promise; /** Injectable cleanup/exit seam used after a successful install. */ onInstallSuccess?: (info: UpdateInfo) => void; } async function installUpdate(packageName: string, version: string, signal?: AbortSignal): Promise { await runUpdateInstall(packageName, version, undefined, signal); } export function UpdateOverlay({ install = installUpdate, onInstallSuccess }: UpdateOverlayProps = {}) { const state = useAppState(); const dispatch = useAppDispatch(); const lifecycle = useTuiLifecycle(); const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions(); const info = state.updateInfo; const [installing, setInstalling] = useState(false); const [installError, setInstallError] = useState(null); const mountedRef = useRef(true); const layout = computeOverlayRect(terminalWidth, terminalHeight, { widthRatio: 0.6, minWidth: 32, height: 12, leftRatio: 0.2, topRatio: 0.3, }); useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); const completeInstall = (installedInfo: UpdateInfo) => { if (onInstallSuccess) { onInstallSuccess(installedInfo); return; } void lifecycle.shutdown().then( () => { process.stdout.write( `\nāœ“ Installed ${sanitizeTerminalText(installedInfo.packageName)}@${sanitizeTerminalText(installedInfo.latestVersion)}. Re-run to use the new version.\n`, ); process.exit(0); }, (error: unknown) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write( `\nUpdate installed, but rss-ai could not shut down cleanly: ${sanitizeTerminalText(message)}\n`, ); process.exit(1); }, ); }; useKeyboard((key) => { if (!info || installing) return; if (key.name === 'n' || key.name === 'enter' || key.name === 'return') { setInstalling(true); setInstallError(null); void lifecycle .run((signal) => install(info.packageName, info.latestVersion, signal)) .then(() => completeInstall(info)) .catch((err: unknown) => { if (isTuiShutdownError(err) || !mountedRef.current || !lifecycle.isAcceptingWork()) return; const message = err instanceof Error ? err.message : String(err); setInstallError(sanitizeTerminalText(message)); setInstalling(false); }); return; } if (key.name === 'l' || key.name === 'escape') { markRemindLater(); dispatch({ type: 'SET_UPDATE_INFO', info: null }); return; } if (key.name === 's') { markVersionSkipped(info.latestVersion); dispatch({ type: 'SET_UPDATE_INFO', info: null }); return; } }); if (!info) return null; const packageName = sanitizeTerminalText(info.packageName); const currentVersion = sanitizeTerminalText(info.currentVersion); const latestVersion = sanitizeTerminalText(info.latestVersion); const versionLine = `${packageName} ${currentVersion} → ${latestVersion}`; return ( {layout.contentHeight >= 1 ? ( terminalCellWidth(versionLine) <= layout.contentWidth ? ( {`${packageName} `} {currentVersion} {' → '} {latestVersion} ) : ( {truncateTerminalText(versionLine, layout.contentWidth)} ) ) : null} {layout.contentHeight >= 4 ? : null} {installing ? ( layout.contentHeight >= 2 ? ( {truncateTerminalText('Installing...', layout.contentWidth)} ) : null ) : installError ? ( <> {layout.contentHeight >= 2 ? ( {truncateTerminalText(`Update failed: ${sanitizeTerminalText(installError)}`, layout.contentWidth)} ) : null} {layout.contentHeight >= 3 ? ( {truncateTerminalText('Press n to retry, l to try later, or s to skip.', layout.contentWidth)} ) : null} {layout.contentHeight >= 4 ? ( {truncateTerminalText(`Manual: ${getInstallCommand(packageName, latestVersion)}`, layout.contentWidth)} ) : null} ) : ( <> {layout.contentHeight >= 2 ? ( terminalCellWidth('n update now l later s skip this version') <= layout.contentWidth ? ( n {' update now '} l {' later '} s {' skip this version'} ) : ( {truncateTerminalText('n update now l later s skip this version', layout.contentWidth)} ) ) : null} {layout.contentHeight >= 4 ? : null} {layout.contentHeight >= 3 ? ( {truncateTerminalText('Esc to dismiss', layout.contentWidth)} ) : null} )} ); }