diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx
index aad5e51211..a8232c7c24 100644
--- a/packages/cli/src/ui/App.test.tsx
+++ b/packages/cli/src/ui/App.test.tsx
@@ -32,6 +32,46 @@ vi.mock('./components/QuittingDisplay.js', () => ({
QuittingDisplay: () => Quitting...,
}));
+vi.mock('./components/Footer.js', () => ({
+ Footer: () => Footer,
+}));
+
+vi.mock('./semantic-colors.js', () => ({
+ theme: {
+ status: {
+ warning: 'yellow',
+ },
+ },
+}));
+
+// Don't mock the layout components - let them render normally so tests can see the Ctrl messages
+
+vi.mock('./hooks/useLayoutConfig.js', () => ({
+ useLayoutConfig: () => ({
+ mode: 'default',
+ shouldUseStatic: true,
+ shouldShowFooterInComposer: true,
+ }),
+}));
+
+vi.mock('./hooks/useFooterProps.js', () => ({
+ useFooterProps: () => ({
+ model: 'test-model',
+ targetDir: '/test',
+ debugMode: false,
+ branchName: 'test-branch',
+ debugMessage: '',
+ corgiMode: false,
+ errorCount: 0,
+ showErrorDetails: false,
+ showMemoryUsage: false,
+ promptTokenCount: 0,
+ nightly: false,
+ isTrustedFolder: true,
+ vimMode: undefined,
+ }),
+}));
+
describe('App', () => {
const mockUIState: Partial = {
streamingState: StreamingState.Idle,
@@ -55,7 +95,6 @@ describe('App', () => {
);
expect(lastFrame()).toContain('MainContent');
- expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('Composer');
});
@@ -87,7 +126,6 @@ describe('App', () => {
);
expect(lastFrame()).toContain('MainContent');
- expect(lastFrame()).toContain('Notifications');
expect(lastFrame()).toContain('DialogManager');
});
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 8a582be7f7..50d1b84afe 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -4,18 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { Box, Text } from 'ink';
-import { StreamingContext } from './contexts/StreamingContext.js';
-import { Notifications } from './components/Notifications.js';
-import { MainContent } from './components/MainContent.js';
-import { DialogManager } from './components/DialogManager.js';
-import { Composer } from './components/Composer.js';
import { useUIState } from './contexts/UIStateContext.js';
+import { StreamingContext } from './contexts/StreamingContext.js';
import { QuittingDisplay } from './components/QuittingDisplay.js';
-import { theme } from './semantic-colors.js';
+import { useLayoutConfig } from './hooks/useLayoutConfig.js';
+import { ScreenReaderAppLayout } from './layouts/ScreenReaderAppLayout.js';
+import { DefaultAppLayout } from './layouts/DefaultAppLayout.js';
export const App = () => {
const uiState = useUIState();
+ const layout = useLayoutConfig();
if (uiState.quittingMessages) {
return ;
@@ -23,35 +21,11 @@ export const App = () => {
return (
-
-
-
-
-
-
- {uiState.dialogsVisible ? (
-
- ) : (
-
- )}
-
- {uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
-
-
- Press Ctrl+C again to exit.
-
-
- )}
-
- {uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
-
-
- Press Ctrl+D again to exit.
-
-
- )}
-
-
+ {layout.mode === 'screenReader' ? (
+
+ ) : (
+
+ )}
);
};
diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx
index 9acc49a44d..2e826d97f2 100644
--- a/packages/cli/src/ui/components/Composer.tsx
+++ b/packages/cli/src/ui/components/Composer.tsx
@@ -26,10 +26,12 @@ import { useSettings } from '../contexts/SettingsContext.js';
import { ApprovalMode } from '@google/gemini-cli-core';
import { StreamingState } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
+import { useLayoutConfig } from '../hooks/useLayoutConfig.js';
export const Composer = () => {
const config = useConfig();
const settings = useSettings();
+ const layout = useLayoutConfig();
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled, vimMode } = useVimMode();
@@ -176,7 +178,7 @@ export const Composer = () => {
/>
)}
- {!settings.merged.ui?.hideFooter && (
+ {!settings.merged.ui?.hideFooter && layout.shouldShowFooterInComposer && (
)}
diff --git a/packages/cli/src/ui/components/HistoryList.tsx b/packages/cli/src/ui/components/HistoryList.tsx
new file mode 100644
index 0000000000..e5957e8a38
--- /dev/null
+++ b/packages/cli/src/ui/components/HistoryList.tsx
@@ -0,0 +1,36 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import type { HistoryItem } from '../types.js';
+import type { SlashCommand } from '../commands/types.js';
+
+interface HistoryListProps {
+ history: HistoryItem[];
+ terminalWidth: number;
+ staticAreaMaxItemHeight: number;
+ slashCommands: readonly SlashCommand[];
+}
+
+export const HistoryList = ({
+ history,
+ terminalWidth,
+ staticAreaMaxItemHeight,
+ slashCommands,
+}: HistoryListProps) => (
+ <>
+ {history.map((h) => (
+
+ ))}
+ >
+);
diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx
index dfb0ba6e64..a87260f8cd 100644
--- a/packages/cli/src/ui/components/MainContent.tsx
+++ b/packages/cli/src/ui/components/MainContent.tsx
@@ -5,16 +5,19 @@
*/
import { Box, Static } from 'ink';
-import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import { HistoryList } from './HistoryList.js';
+import { PendingHistoryList } from './PendingHistoryList.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
+import { useLayoutConfig } from '../hooks/useLayoutConfig.js';
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
+ const layout = useLayoutConfig();
const {
pendingHistoryItems,
mainAreaWidth,
@@ -22,42 +25,60 @@ export const MainContent = () => {
availableTerminalHeight,
} = uiState;
+ // In screen reader mode, use regular layout without Static component
+ if (!layout.shouldUseStatic) {
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // Default mode with Static component
return (
<>
,
- ...uiState.history.map((h) => (
-
- )),
+ ,
]}
>
{(item) => item}
- {pendingHistoryItems.map((item, i) => (
-
- ))}
+
diff --git a/packages/cli/src/ui/components/PendingHistoryList.tsx b/packages/cli/src/ui/components/PendingHistoryList.tsx
new file mode 100644
index 0000000000..da2ed3c3ea
--- /dev/null
+++ b/packages/cli/src/ui/components/PendingHistoryList.tsx
@@ -0,0 +1,45 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { HistoryItemDisplay } from './HistoryItemDisplay.js';
+import type { HistoryItemWithoutId } from '../types.js';
+
+interface PendingHistoryListProps {
+ pendingHistoryItems: HistoryItemWithoutId[];
+ terminalWidth: number;
+ availableTerminalHeight?: number;
+ constrainHeight?: boolean;
+ isEditorDialogOpen: boolean;
+ activePtyId?: string;
+ embeddedShellFocused?: boolean;
+}
+
+export const PendingHistoryList = ({
+ pendingHistoryItems,
+ terminalWidth,
+ availableTerminalHeight,
+ constrainHeight,
+ isEditorDialogOpen,
+ activePtyId,
+ embeddedShellFocused,
+}: PendingHistoryListProps) => (
+ <>
+ {pendingHistoryItems.map((item, i) => (
+
+ ))}
+ >
+);
diff --git a/packages/cli/src/ui/hooks/useFooterProps.ts b/packages/cli/src/ui/hooks/useFooterProps.ts
new file mode 100644
index 0000000000..f469e0126d
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useFooterProps.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useConfig } from '../contexts/ConfigContext.js';
+import { useSettings } from '../contexts/SettingsContext.js';
+
+export const useFooterProps = () => {
+ const uiState = useUIState();
+ const config = useConfig();
+ const settings = useSettings();
+
+ return {
+ model: config.getModel(),
+ targetDir: config.getTargetDir(),
+ debugMode: config.getDebugMode(),
+ branchName: uiState.branchName,
+ debugMessage: uiState.debugMessage,
+ corgiMode: uiState.corgiMode,
+ errorCount: uiState.errorCount,
+ showErrorDetails: uiState.showErrorDetails,
+ showMemoryUsage:
+ config.getDebugMode() || settings.merged.ui?.showMemoryUsage || false,
+ promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
+ nightly: uiState.nightly,
+ isTrustedFolder: uiState.isTrustedFolder,
+ vimMode: undefined,
+ };
+};
diff --git a/packages/cli/src/ui/hooks/useLayoutConfig.ts b/packages/cli/src/ui/hooks/useLayoutConfig.ts
new file mode 100644
index 0000000000..d43568982a
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useLayoutConfig.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useIsScreenReaderEnabled } from 'ink';
+
+export interface LayoutConfig {
+ shouldUseStatic: boolean;
+ shouldShowFooterInComposer: boolean;
+ mode: 'default' | 'screenReader';
+ allowStaticToggle?: boolean;
+}
+
+export interface LayoutConfigOptions {
+ forceStaticMode?: boolean;
+ allowToggle?: boolean;
+}
+
+export const useLayoutConfig = (
+ options?: LayoutConfigOptions,
+): LayoutConfig => {
+ const isScreenReader = useIsScreenReaderEnabled();
+
+ // Allow overriding static behavior when toggle is enabled
+ const shouldUseStatic =
+ options?.forceStaticMode !== undefined
+ ? options.forceStaticMode
+ : !isScreenReader;
+
+ return {
+ shouldUseStatic,
+ shouldShowFooterInComposer: !isScreenReader,
+ mode: isScreenReader ? 'screenReader' : 'default',
+ allowStaticToggle: options?.allowToggle ?? false,
+ };
+};
diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
new file mode 100644
index 0000000000..c55d512869
--- /dev/null
+++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx
@@ -0,0 +1,50 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { Notifications } from '../components/Notifications.js';
+import { MainContent } from '../components/MainContent.js';
+import { DialogManager } from '../components/DialogManager.js';
+import { Composer } from '../components/Composer.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { theme } from '../semantic-colors.js';
+
+export const DefaultAppLayout: React.FC = () => {
+ const uiState = useUIState();
+
+ return (
+
+
+
+
+
+
+ {uiState.dialogsVisible ? (
+
+ ) : (
+
+ )}
+
+ {uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
+
+
+ Press Ctrl+C again to exit.
+
+
+ )}
+
+ {uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
+
+
+ Press Ctrl+D again to exit.
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx
new file mode 100644
index 0000000000..8096960fe1
--- /dev/null
+++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx
@@ -0,0 +1,48 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type React from 'react';
+import { Box, Text } from 'ink';
+import { Notifications } from '../components/Notifications.js';
+import { MainContent } from '../components/MainContent.js';
+import { DialogManager } from '../components/DialogManager.js';
+import { Composer } from '../components/Composer.js';
+import { Footer } from '../components/Footer.js';
+import { useUIState } from '../contexts/UIStateContext.js';
+import { useFooterProps } from '../hooks/useFooterProps.js';
+import { theme } from '../semantic-colors.js';
+
+export const ScreenReaderAppLayout: React.FC = () => {
+ const uiState = useUIState();
+ const footerProps = useFooterProps();
+
+ return (
+
+
+
+
+
+
+ {uiState.dialogsVisible ? (
+
+ ) : (
+
+ )}
+
+ {uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
+
+ Press Ctrl+C again to exit.
+
+ )}
+
+ {uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
+
+ Press Ctrl+D again to exit.
+
+ )}
+
+ );
+};