diff --git a/docs/design/npm-background-auto-update.md b/docs/design/npm-background-auto-update.md new file mode 100644 index 00000000000..c3236c2ef2a --- /dev/null +++ b/docs/design/npm-background-auto-update.md @@ -0,0 +1,60 @@ +# Background npm updates + +## Problem + +The published CLI is code-split into content-hashed JavaScript chunks. Running +`npm install -g` from an active session replaces those chunks in place, so a +later dynamic import in the old process can fail with `ERR_MODULE_NOT_FOUND`. +Deferring the install until session exit avoids corruption, but turns a +background update into an exit-time delay and gives users no benefit until they +leave the session. + +## Design + +For writable global npm installations, the post-render update check installs +the exact resolved version under a directory derived from the global launcher: + +```text +~/.qwen/updates/npm//versions// +``` + +The version check runs npm in its global context and the staged install uses an +isolated prefix. The staged command explicitly preserves the original global +npm configuration, so changing the prefix does not switch registry or +authentication settings between discovery and installation. + +The launcher resolves `QWEN_HOME` from the same home-scoped `.env` files before +selecting a version. This keeps the bootstrap path aligned with CLI storage even +though the full environment loader runs later. + +The install and activation run in a detached worker, so quitting the TUI does +not interrupt an update already in progress. After npm exits successfully, the +worker verifies the package name, version, bundle, and launcher, then atomically +writes an `active.json` pointer beside that launcher's versions. The global npm +package is not modified. The already-running process and any child commands it +starts remain pinned to their original build. On the next invocation, the +stable launcher reads the pointer and starts the verified version directory. + +Each global npm launcher has its own pointer and version payloads, so +installations under different npm or nvm prefixes can share `~/.qwen` without +overriding one another or sharing dependencies. A slower concurrent update +cannot replace a newer active version. + +An incomplete install never changes the active pointer. Before activation, the +worker validates the installed manifest and runs a launcher smoke test. A +missing, malformed, or launcher-mismatched pointer is ignored and the original +npm package remains the fallback. The pointer also records the base package and +launcher identity, so a later explicit global npm install supersedes the +managed version. Because the launcher is not replaced by managed updates, +existing `active.json` fields are a compatibility contract: future changes may +add fields but must not remove or reinterpret them. + +Version directories are retained because an older live session may still load +from them. Cleanup is intentionally deferred until disk usage shows that a +lease-based collector is necessary. + +## Scope + +This changes automatic updates for npm installations only. Other package +managers and standalone archives keep the existing exit-safe behavior until +they have an equivalent immutable-version installation layout. diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index d1b6dac2ff3..af20ba5db5d 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -12,10 +12,15 @@ import { mkdirSync, mkdtempSync, readFileSync, + realpathSync, + renameSync, rmSync, + statSync, + utimesSync, writeFileSync, } from 'node:fs'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { FatalError } from '@qwen-code/qwen-code-core'; @@ -42,6 +47,7 @@ const mocks = vi.hoisted(() => ({ mcpListHandler: vi.fn(), mcpAddHandler: vi.fn(), getCliVersion: vi.fn(), + installManagedNpmUpdate: vi.fn(), })); vi.mock('./gemini.js', () => ({ @@ -69,6 +75,10 @@ vi.mock('./utils/version.js', () => ({ getCliVersion: mocks.getCliVersion, })); +vi.mock('./utils/managed-npm-update.js', () => ({ + installManagedNpmUpdate: mocks.installManagedNpmUpdate, +})); + vi.mock('./commands/mcp.js', () => ({ mcpCommand: { command: 'mcp', @@ -133,6 +143,8 @@ describe('resolveBootstrapRoute', () => { describe('runCliEntry', () => { const savedEnv = { CLI_VERSION: process.env['CLI_VERSION'], + QWEN_CODE_MANAGED_NPM_UPDATE_VERSION: + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'], }; let stdout: string[]; @@ -165,6 +177,12 @@ describe('runCliEntry', () => { } else { process.env['CLI_VERSION'] = savedEnv.CLI_VERSION; } + if (savedEnv.QWEN_CODE_MANAGED_NPM_UPDATE_VERSION === undefined) { + delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']; + } else { + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'] = + savedEnv.QWEN_CODE_MANAGED_NPM_UPDATE_VERSION; + } vi.restoreAllMocks(); }); @@ -178,6 +196,16 @@ describe('runCliEntry', () => { expect(mocks.initCpuProfiler).not.toHaveBeenCalled(); }); + it('runs a managed update worker without starting the CLI', async () => { + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION'] = '2.0.0'; + + await runCliEntry([]); + + expect(mocks.installManagedNpmUpdate).toHaveBeenCalledWith('2.0.0'); + expect(process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']).toBeUndefined(); + expect(mocks.main).not.toHaveBeenCalled(); + }); + it('falls back to getCliVersion when CLI_VERSION is unset', async () => { delete process.env['CLI_VERSION']; @@ -502,6 +530,254 @@ describe('bootstrap import boundaries', () => { expect(output).toBe(`${expectedVersion}\n`); }); + it('resolves and pins managed updates from the configured home', () => { + const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-managed-npm-')); + const entryDir = path.join(tempDir, 'bootstrap'); + const entryPath = path.join(entryDir, 'cli-entry.mjs'); + const qwenHome = path.join(tempDir, 'custom', 'qwen'); + try { + mkdirSync(entryDir, { recursive: true }); + copyFileSync('../../scripts/cli-entry.js', entryPath); + const bootstrapId = createHash('sha256') + .update(realpathSync(entryPath)) + .digest('hex') + .slice(0, 16); + const launcherRoot = path.join(qwenHome, 'updates', 'npm', bootstrapId); + const packageRoot = path.join( + launcherRoot, + 'versions', + '2.0.0', + 'node_modules', + '@qwen-code', + 'qwen-code', + ); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync( + path.join(entryDir, 'cli.js'), + "process.stdout.write(JSON.stringify({ build: 'base', pin: process.env.QWEN_CODE_MANAGED_NPM_PIN }));\n", + ); + writeFileSync( + path.join(entryDir, 'package.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + version: '1.0.0', + }), + ); + writeFileSync( + path.join(packageRoot, 'package.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + version: '2.0.0', + }), + ); + writeFileSync( + path.join(packageRoot, 'cli.js'), + "process.stdout.write(JSON.stringify({ build: 'managed-2', managed: process.env.QWEN_CODE_MANAGED_NPM_UPDATE, launcher: process.env.QWEN_CODE_CLI, pin: process.env.QWEN_CODE_MANAGED_NPM_PIN, args: process.argv.slice(2) }));\n", + ); + mkdirSync(launcherRoot, { recursive: true }); + const bootstrapStat = statSync(entryPath); + + mkdirSync(path.join(tempDir, '.qwen'), { recursive: true }); + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + '\uFEFFQWEN_HOME: ~\\custom\\qwen\n', + ); + const childEnv: NodeJS.ProcessEnv = { + ...process.env, + HOME: tempDir, + USERPROFILE: tempDir, + TMPDIR: tempDir, + TEMP: tempDir, + TMP: tempDir, + }; + delete childEnv['QWEN_HOME']; + delete childEnv['QWEN_CODE_MANAGED_NPM_PIN']; + const baseSession = JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: childEnv, + }), + ) as { build: string; pin: string }; + expect(baseSession.build).toBe('base'); + + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '2.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapCtimeMs: bootstrapStat.ctimeMs, + }), + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { + ...childEnv, + QWEN_CODE_MANAGED_NPM_PIN: baseSession.pin, + }, + }), + ), + ).toMatchObject({ build: 'base' }); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { ...childEnv, QWEN_HOME: '' }, + }), + ), + ).toMatchObject({ build: 'base' }); + + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + `QWEN_HOME:${qwenHome}\n`, + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: childEnv, + }), + ), + ).toMatchObject({ build: 'base' }); + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + `QWEN_HOME: \nOTHER=${qwenHome}\n`, + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: childEnv, + }), + ), + ).toMatchObject({ build: 'base' }); + writeFileSync( + path.join(tempDir, '.qwen', '.env'), + '\uFEFFQWEN_HOME: ~\\custom\\qwen\n', + ); + const output = execFileSync( + process.execPath, + [entryPath, '--prompt', 'hello'], + { + encoding: 'utf8', + env: childEnv, + }, + ); + + const managedSession = JSON.parse(output) as { + build: string; + managed: string; + launcher: string; + pin: string; + args: string[]; + }; + expect(managedSession).toMatchObject({ + build: 'managed-2', + managed: 'true', + launcher: realpathSync(entryPath), + args: ['--prompt', 'hello'], + }); + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '3.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapCtimeMs: bootstrapStat.ctimeMs, + }), + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { + ...childEnv, + QWEN_HOME: 'different-relative-home', + QWEN_CODE_MANAGED_NPM_PIN: managedSession.pin, + }, + }), + ), + ).toMatchObject({ build: 'managed-2' }); + writeFileSync( + path.join(launcherRoot, 'active.json'), + JSON.stringify({ + version: '2.0.0', + bootstrap: realpathSync(entryPath), + baseVersion: '1.0.0', + bootstrapCtimeMs: bootstrapStat.ctimeMs, + }), + ); + + const emptyHomeRoot = path.join(tempDir, 'empty-home'); + const emptyQwenHome = path.join(emptyHomeRoot, '.qwen'); + mkdirSync(emptyQwenHome, { recursive: true }); + renameSync( + path.join(qwenHome, 'updates'), + path.join(emptyQwenHome, 'updates'), + ); + const emptyHomeEnv = { + ...childEnv, + HOME: '', + USERPROFILE: '', + HOMEDRIVE: '', + HOMEPATH: '', + TMPDIR: emptyHomeRoot, + TEMP: emptyHomeRoot, + TMP: emptyHomeRoot, + }; + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: emptyHomeEnv, + }), + ), + ).toMatchObject({ + build: 'managed-2', + managed: 'true', + launcher: realpathSync(entryPath), + args: ['--prompt', 'hello'], + }); + + const replacement = `${entryPath}.replacement`; + copyFileSync(entryPath, replacement); + renameSync(replacement, entryPath); + utimesSync(entryPath, bootstrapStat.atime, bootstrapStat.mtime); + expect(statSync(entryPath).ctimeMs).not.toBe(bootstrapStat.ctimeMs); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: emptyHomeEnv, + }), + ), + ).toMatchObject({ build: 'base' }); + + writeFileSync( + path.join(entryDir, 'package.json'), + JSON.stringify({ + name: '@qwen-code/qwen-code', + version: '3.0.0', + }), + ); + expect( + JSON.parse( + execFileSync(process.execPath, [entryPath, '--prompt', 'hello'], { + encoding: 'utf8', + env: { + ...emptyHomeEnv, + QWEN_CODE_MANAGED_NPM_UPDATE: 'true', + }, + }), + ), + ).toMatchObject({ build: 'base' }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }, 60_000); + it('falls through to cli.js when wrapper package.json lookup fails', () => { const tempDir = mkdtempSync(path.join(tmpdir(), 'qwen-cli-entry-')); const entryDir = path.join(tempDir, 'bin'); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 56592777c72..e9743bbf0ea 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -339,6 +339,17 @@ async function parseYargsCommand( export async function runCliEntry( rawArgv: readonly string[] = process.argv.slice(2), ): Promise { + const managedUpdateVersion = + process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']; + if (managedUpdateVersion) { + delete process.env['QWEN_CODE_MANAGED_NPM_UPDATE_VERSION']; + const { installManagedNpmUpdate } = await import( + './utils/managed-npm-update.js' + ); + await installManagedNpmUpdate(managedUpdateVersion); + return; + } + const argv = normalizeServeFastPathArgv(rawArgv); const route = resolveBootstrapRoute(argv); diff --git a/packages/cli/src/startup/startup-prefetch.test.ts b/packages/cli/src/startup/startup-prefetch.test.ts index 5a278ff9581..21bf507dbce 100644 --- a/packages/cli/src/startup/startup-prefetch.test.ts +++ b/packages/cli/src/startup/startup-prefetch.test.ts @@ -128,6 +128,7 @@ describe('startupPrefetch', () => { }); mockGetInstallationInfo.mockReturnValue({ updateCommand: 'npm install -g @qwen-code/qwen-code@latest', + packageManager: 'npm', isStandalone: false, }); mockRequestUpdateOnExit.mockReturnValue(true); @@ -220,8 +221,9 @@ describe('startupPrefetch', () => { ); }); - it('defers an available update until the session exits', async () => { + it('installs npm updates in the background after first render', async () => { const config = makeConfig(); + const settings = makeSettings(); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { @@ -230,19 +232,49 @@ describe('startupPrefetch', () => { }, }); - startPostRenderPrefetches(config, makeSettings()); + startPostRenderPrefetches(config, settings); await vi.dynamicImportSettled(); - expect(mockRequestUpdateOnExit).toHaveBeenCalledTimes(1); - expect(mockUpdateEventEmit).toHaveBeenCalledWith('update-info', { - message: - 'Update available\nThe update will be installed after you exit this session.', + expect(mockHandleAutoUpdate).toHaveBeenCalledWith( + { + message: 'Update available', + update: { latest: '2.0.0' }, + }, + settings, + '/repo', + ); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); + }); + + it('keeps manual guidance for npm installs without an update command', async () => { + mockGetInstallationInfo.mockReturnValue({ + packageManager: 'npm', + isGlobal: true, + updateMessage: 'Update requires sudo.', + }); + mockCheckForUpdatesDetailed.mockResolvedValue({ + status: 'update', + info: { + message: 'Update available', + update: { latest: '2.0.0' }, + }, }); + + startPostRenderPrefetches(makeConfig(), makeSettings()); + await vi.dynamicImportSettled(); + + expect(mockHandleAutoUpdate).toHaveBeenCalledOnce(); + expect(mockRequestUpdateOnExit).not.toHaveBeenCalled(); }); - it('prompts for an explicit update when no parent supervisor is available', async () => { + it('prompts non-npm installs when no parent supervisor is available', async () => { mockRequestUpdateOnExit.mockReturnValue(false); + mockGetInstallationInfo.mockReturnValue({ + updateCommand: 'pnpm add -g @qwen-code/qwen-code@latest', + packageManager: 'pnpm', + isGlobal: true, + }); mockCheckForUpdatesDetailed.mockResolvedValue({ status: 'update', info: { @@ -270,6 +302,7 @@ describe('startupPrefetch', () => { }); mockGetInstallationInfo.mockReturnValue({ updateCommand: 'standalone update', + packageManager: 'standalone', isStandalone: true, standaloneDir: '/tmp/qwen-code', }); diff --git a/packages/cli/src/startup/startup-prefetch.ts b/packages/cli/src/startup/startup-prefetch.ts index cf92bba8b33..5124d457e67 100644 --- a/packages/cli/src/startup/startup-prefetch.ts +++ b/packages/cli/src/startup/startup-prefetch.ts @@ -196,6 +196,10 @@ export function startPostRenderPrefetches( return; } const installationInfo = getInstallationInfo(projectRoot, true); + if (installationInfo.packageManager === 'npm') { + void handleAutoUpdate(result.info, settings, projectRoot); + return; + } if ( installationInfo.updateCommand || (installationInfo.isStandalone && installationInfo.standaloneDir) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 293dd4cd028..6a4574df78c 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -3040,10 +3040,13 @@ export const AppContainer = (props: AppContainerProps) => { dialogsVisible, stickyTodosLayoutKey, liveAgentPanelLayoutKey, - // Composer height also shifts with these; without them the footer isn't - // re-measured during a streaming turn and the VP viewport bottom clips. + // Composer and update notification height also shift with these; without + // them the footer isn't re-measured during a streaming turn and the VP + // viewport bottom clips. // (elapsedTime/currentLoadingPhrase excluded: they tick without changing rows.) streamingState, + updateInfo, + agentViewState.activeView, embeddedShellFocused, messageQueue.length, isInputActive, diff --git a/packages/cli/src/ui/components/Notifications.tsx b/packages/cli/src/ui/components/Notifications.tsx index 59dec3cf894..c7e75c8fa1e 100644 --- a/packages/cli/src/ui/components/Notifications.tsx +++ b/packages/cli/src/ui/components/Notifications.tsx @@ -9,11 +9,10 @@ import { useAppContext } from '../contexts/AppContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { theme } from '../semantic-colors.js'; import { StreamingState } from '../types.js'; -import { UpdateNotification } from './UpdateNotification.js'; export const Notifications = () => { const { startupWarnings } = useAppContext(); - const { initError, streamingState, updateInfo } = useUIState(); + const { initError, streamingState } = useUIState(); const showStartupWarnings = startupWarnings.length > 0; const showInitError = @@ -21,7 +20,6 @@ export const Notifications = () => { return ( <> - {updateInfo && } {showStartupWarnings && ( ({ MainContent: () => MainContent, })); +vi.mock('../components/UpdateNotification.js', () => ({ + UpdateNotification: ({ message }: { message: string }) => ( + {`UpdateNotification: ${message}`} + ), +})); + vi.mock('../components/DialogManager.js', () => ({ DialogManager: () => ( @@ -140,6 +146,32 @@ describe('DefaultAppLayout', () => { ); }); + it('renders an update that arrives after startup above the composer', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'main', + agents: new Map(), + }); + + const { lastFrame } = renderLayout({ + ...baseUIState, + updateInfo: { + message: 'Update successful!', + update: { + latest: '0.20.0', + current: '0.19.12', + type: 'latest', + name: '@qwen-code/qwen-code', + }, + }, + }); + const output = lastFrame() ?? ''; + + expect(output).toContain('UpdateNotification: Update successful!'); + expect(output.indexOf('UpdateNotification')).toBeLessThan( + output.indexOf('Composer'), + ); + }); + it('does not render sticky todo list when dialogs are visible', () => { mockedUseAgentViewState.mockReturnValue({ activeView: 'main', @@ -257,4 +289,30 @@ describe('DefaultAppLayout', () => { expect(output).toContain('AgentChatView'); expect(output).toContain('AgentComposer'); }); + + it('renders update notifications in an agent tab view', () => { + mockedUseAgentViewState.mockReturnValue({ + activeView: 'agent-1', + agents: new Map([['agent-1', {}]]), + }); + + const { lastFrame } = renderLayout({ + ...baseUIState, + updateInfo: { + message: 'Update successful!', + update: { + latest: '0.20.0', + current: '0.19.12', + type: 'latest', + name: '@qwen-code/qwen-code', + }, + }, + }); + const output = lastFrame() ?? ''; + + expect(output).toContain('UpdateNotification: Update successful!'); + expect(output.indexOf('UpdateNotification')).toBeLessThan( + output.indexOf('AgentComposer'), + ); + }); }); diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 76159ca1e3e..242c3238af4 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { useEffect, useRef } from 'react'; import { Box } from 'ink'; import { MainContent } from '../components/MainContent.js'; +import { UpdateNotification } from '../components/UpdateNotification.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; @@ -68,6 +69,9 @@ export const DefaultAppLayout: React.FC = () => { {/* Agent view: chat history + agent-specific composer */} + {!uiState.dialogsVisible && uiState.updateInfo && ( + + )} @@ -77,6 +81,9 @@ export const DefaultAppLayout: React.FC = () => { {/* Main view: conversation history + main composer / dialogs */} + {!uiState.dialogsVisible && uiState.updateInfo && ( + + )} {uiState.dialogsVisible ? ( ({ MainContent: () => MainContent, })); +vi.mock('../components/UpdateNotification.js', () => ({ + UpdateNotification: ({ message }: { message: string }) => ( + {`UpdateNotification: ${message}`} + ), +})); + vi.mock('../components/DialogManager.js', () => ({ DialogManager: () => ( @@ -120,6 +126,43 @@ describe('ScreenReaderAppLayout', () => { expect(output).toContain('DialogManager 1'); }); + it('renders update notifications when no dialog is visible', () => { + const { lastFrame } = renderLayout({ + ...baseUIState, + updateInfo: { + message: 'Update successful!', + update: { + latest: '0.20.0', + current: '0.19.12', + type: 'latest', + name: '@qwen-code/qwen-code', + }, + }, + }); + + expect(lastFrame() ?? '').toContain( + 'UpdateNotification: Update successful!', + ); + }); + + it('does not render update notifications when dialogs are visible', () => { + const { lastFrame } = renderLayout({ + ...baseUIState, + dialogsVisible: true, + updateInfo: { + message: 'Update successful!', + update: { + latest: '0.20.0', + current: '0.19.12', + type: 'latest', + name: '@qwen-code/qwen-code', + }, + }, + }); + + expect(lastFrame() ?? '').not.toContain('UpdateNotification'); + }); + it('keeps a tall dialog within the terminal frame when constrained', () => { dialogManagerMockState.lineCount = 20; const terminalHeight = 8; diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index 91964376aa5..4ceedaf5c0a 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -7,6 +7,7 @@ import type React from 'react'; import { Box } from 'ink'; import { Notifications } from '../components/Notifications.js'; +import { UpdateNotification } from '../components/UpdateNotification.js'; import { MainContent } from '../components/MainContent.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; @@ -39,6 +40,9 @@ export const ScreenReaderAppLayout: React.FC = () => { return ( + {!uiState.dialogsVisible && uiState.updateInfo && ( + + )}