diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index b0fd20d311..b27a4a2c97 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -16,7 +16,11 @@ import { } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; -import * as core from '@google/gemini-cli-core'; +import { + debugLogger, + type FolderTrustDiscoveryService, + type GeminiCLIExtension, +} from '@google/gemini-cli-core'; import { ExtensionManager, type inferInstallMetadata, @@ -52,7 +56,7 @@ const mockIsWorkspaceTrusted: Mock = vi.hoisted(() => const mockLoadTrustedFolders: Mock = vi.hoisted(() => vi.fn(), ); -const mockDiscover: Mock = +const mockDiscover: Mock = vi.hoisted(() => vi.fn()); vi.mock('../../config/extensions/consent.js', () => ({ @@ -117,8 +121,8 @@ describe('handleInstall', () => { let processSpy: MockInstance; beforeEach(() => { - debugLogSpy = vi.spyOn(core.debugLogger, 'log'); - debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); + debugLogSpy = vi.spyOn(debugLogger, 'log'); + debugErrorSpy = vi.spyOn(debugLogger, 'error'); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); @@ -171,8 +175,8 @@ describe('handleInstall', () => { }); function createMockExtension( - overrides: Partial = {}, - ): core.GeminiCLIExtension { + overrides: Partial = {}, + ): GeminiCLIExtension { return { name: 'mock-extension', version: '1.0.0', diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 72c55a64b3..d7be970b26 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -21,6 +21,11 @@ import { type MCPServerConfig, type GeminiCLIExtension, Storage, + PolicyDecision, + TelemetryTarget, + loadServerHierarchicalMemory, + createPolicyEngineConfig, + type Config as CoreConfig, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { @@ -28,7 +33,6 @@ import { type MergedSettings, createTestMergedSettings, } from './settings.js'; -import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { ExtensionManager } from './extension-manager.js'; @@ -99,9 +103,9 @@ vi.mock('read-package-up', () => ({ })); vi.mock('@google/gemini-cli-core', async () => { - const actualServer = await vi.importActual( - '@google/gemini-cli-core', - ); + const actualServer = await vi.importActual< + typeof import('@google/gemini-cli-core') + >('@google/gemini-cli-core'); return { ...actualServer, IdeClient: { @@ -146,8 +150,8 @@ vi.mock('@google/gemini-cli-core', async () => { createPolicyEngineConfig: vi.fn(async () => ({ rules: [], checkers: [], - defaultDecision: ServerConfig.PolicyDecision.ASK_USER, - approvalMode: ServerConfig.ApprovalMode.DEFAULT, + defaultDecision: PolicyDecision.ASK_USER, + approvalMode: ApprovalMode.DEFAULT, })), getAdminErrorMessage: vi.fn( (_feature) => @@ -846,7 +850,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { ]); const argv = await parseArguments(createTestMergedSettings()); await loadCliConfig(settings, 'session-id', argv); - expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( + expect(loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], expect.any(Object), @@ -874,7 +878,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { const argv = await parseArguments(settings); await loadCliConfig(settings, 'session-id', argv); - expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( + expect(loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [includeDir], expect.any(Object), @@ -901,7 +905,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { const argv = await parseArguments(settings); await loadCliConfig(settings, 'session-id', argv); - expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( + expect(loadServerHierarchicalMemory).toHaveBeenCalledWith( expect.any(String), [], expect.any(Object), @@ -2500,7 +2504,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should set YOLO approval mode when --yolo flag is used', async () => { @@ -2511,7 +2515,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + expect(config.getApprovalMode()).toBe(ApprovalMode.YOLO); }); it('should set YOLO approval mode when -y flag is used', async () => { @@ -2522,7 +2526,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + expect(config.getApprovalMode()).toBe(ApprovalMode.YOLO); }); it('should set DEFAULT approval mode when --approval-mode=default', async () => { @@ -2533,7 +2537,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => { @@ -2544,7 +2548,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.AUTO_EDIT); + expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); }); it('should set YOLO approval mode when --approval-mode=yolo', async () => { @@ -2555,7 +2559,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + expect(config.getApprovalMode()).toBe(ApprovalMode.YOLO); }); it('should prioritize --approval-mode over --yolo when both would be valid (but validation prevents this)', async () => { @@ -2570,7 +2574,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should fall back to --yolo behavior when --approval-mode is not set', async () => { @@ -2581,7 +2585,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + expect(config.getApprovalMode()).toBe(ApprovalMode.YOLO); }); it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => { @@ -2593,7 +2597,7 @@ describe('loadCliConfig approval mode', () => { }, }); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); }); it('should ignore "yolo" in settings.tools.approvalMode and fall back to DEFAULT', async () => { @@ -2606,7 +2610,7 @@ describe('loadCliConfig approval mode', () => { }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => { @@ -2663,7 +2667,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should override --approval-mode=auto_edit to DEFAULT', async () => { @@ -2674,7 +2678,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should override --yolo flag to DEFAULT', async () => { @@ -2685,7 +2689,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); it('should remain DEFAULT when --approval-mode=default', async () => { @@ -2696,7 +2700,7 @@ describe('loadCliConfig approval mode', () => { 'test-session', argv, ); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); + expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT); }); }); @@ -2708,9 +2712,7 @@ describe('loadCliConfig approval mode', () => { }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe( - ServerConfig.ApprovalMode.AUTO_EDIT, - ); + expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); }); it('should prioritize --approval-mode flag over settings', async () => { @@ -2720,9 +2722,7 @@ describe('loadCliConfig approval mode', () => { }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe( - ServerConfig.ApprovalMode.AUTO_EDIT, - ); + expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT); }); it('should prioritize --yolo flag over settings', async () => { @@ -2732,7 +2732,7 @@ describe('loadCliConfig approval mode', () => { }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO); + expect(config.getApprovalMode()).toBe(ApprovalMode.YOLO); }); it('should respect plan mode from settings when experimental.plan is enabled', async () => { @@ -2743,7 +2743,7 @@ describe('loadCliConfig approval mode', () => { }); const argv = await parseArguments(settings); const config = await loadCliConfig(settings, 'test-session', argv); - expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN); + expect(config.getApprovalMode()).toBe(ApprovalMode.PLAN); }); it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => { @@ -2841,7 +2841,7 @@ describe('loadCliConfig fileFiltering', () => { >; const testCases: Array<{ property: keyof FileFilteringSettings; - getter: (config: ServerConfig.Config) => boolean; + getter: (config: CoreConfig) => boolean; value: boolean; }> = [ { @@ -3085,7 +3085,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ - telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, + telemetry: { target: TelemetryTarget.LOCAL }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('gcp'); @@ -3096,7 +3096,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ - telemetry: { target: ServerConfig.TelemetryTarget.GCP }, + telemetry: { target: TelemetryTarget.GCP }, }); await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( /Invalid telemetry configuration: .*Invalid telemetry target/i, @@ -3174,7 +3174,7 @@ describe('Telemetry configuration via environment variables', () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(createTestMergedSettings()); const settings = createTestMergedSettings({ - telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, + telemetry: { target: TelemetryTarget.LOCAL }, }); const config = await loadCliConfig(settings, 'test-session', argv); expect(config.getTelemetryTarget()).toBe('local'); @@ -3298,7 +3298,7 @@ describe('Policy Engine Integration in loadCliConfig', () => { await loadCliConfig(settings, 'test-session', argv); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ allowed: expect.arrayContaining(['cli-tool']), @@ -3319,7 +3319,7 @@ describe('Policy Engine Integration in loadCliConfig', () => { await loadCliConfig(settings, 'test-session', argv); // In non-interactive mode, only ask_user is excluded by default - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ tools: expect.objectContaining({ exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]), @@ -3341,7 +3341,7 @@ describe('Policy Engine Integration in loadCliConfig', () => { await loadCliConfig(settings, 'test-session', argv); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'], }), diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index ab6a22fb64..d7cc3ed0e4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -97,6 +97,7 @@ export interface CliArgs { rawOutput: boolean | undefined; acceptRawOutputRisk: boolean | undefined; isCommand: boolean | undefined; + fullscreen?: boolean; } /** @@ -297,6 +298,10 @@ export async function parseArguments( .option('accept-raw-output-risk', { type: 'boolean', description: 'Suppress the security warning when using --raw-output.', + }) + .option('fullscreen', { + type: 'boolean', + description: 'Enable experimental fullscreen mode.', }), ) // Register MCP subcommands @@ -435,6 +440,10 @@ export async function loadCliConfig( process.env['GEMINI_SANDBOX'] = 'true'; } + if (argv.fullscreen !== undefined) { + settings.experimental.fullscreen = argv.fullscreen; + } + const memoryImportFormat = settings.context?.importFormat || 'tree'; const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true; diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 87fbe98fc3..0609f51831 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1928,6 +1928,16 @@ const SETTINGS_SCHEMA = { description: 'Enable Plan Mode.', showInDialog: true, }, + fullscreen: { + type: 'boolean', + label: 'Fullscreen Mode', + category: 'Experimental', + requiresRestart: false, + default: false, + description: + 'Enable experimental fullscreen mode for integrated and background shells (toggle with ctrl+7).', + showInDialog: true, + }, taskTracker: { type: 'boolean', label: 'Task Tracker', diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index d0d98a5a31..9d4a790216 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as path from 'node:path'; import { loadCliConfig, type CliArgs } from './config.js'; import { createTestMergedSettings } from './settings.js'; -import * as ServerConfig from '@google/gemini-cli-core'; +import { + isHeadlessMode, + Storage, + createPolicyEngineConfig, +} from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; import * as Policy from './policy.js'; @@ -21,9 +25,9 @@ const mockCheckIntegrity = vi.fn(); const mockAcceptIntegrity = vi.fn(); vi.mock('@google/gemini-cli-core', async () => { - const actual = await vi.importActual( - '@google/gemini-cli-core', - ); + const actual = await vi.importActual< + typeof import('@google/gemini-cli-core') + >('@google/gemini-cli-core'); return { ...actual, loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ @@ -61,11 +65,11 @@ describe('Workspace-Level Policy CLI Integration', () => { hash: 'test-hash', fileCount: 1, }); - vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); + vi.mocked(isHeadlessMode).mockReturnValue(false); }); it('should have getWorkspacePoliciesDir on Storage class', () => { - const storage = new ServerConfig.Storage(MOCK_CWD); + const storage = new Storage(MOCK_CWD); expect(storage.getWorkspacePoliciesDir).toBeDefined(); expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); }); @@ -81,7 +85,7 @@ describe('Workspace-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), @@ -102,7 +106,7 @@ describe('Workspace-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: undefined, }), @@ -126,7 +130,7 @@ describe('Workspace-Level Policy CLI Integration', () => { await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: undefined, }), @@ -144,7 +148,7 @@ describe('Workspace-Level Policy CLI Integration', () => { hash: 'new-hash', fileCount: 1, }); - vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive + vi.mocked(isHeadlessMode).mockReturnValue(true); // Non-interactive const settings = createTestMergedSettings(); const argv = { prompt: 'do something' } as unknown as CliArgs; @@ -156,7 +160,7 @@ describe('Workspace-Level Policy CLI Integration', () => { MOCK_CWD, 'new-hash', ); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), @@ -176,7 +180,7 @@ describe('Workspace-Level Policy CLI Integration', () => { hash: 'new-hash', fileCount: 1, }); - vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + vi.mocked(isHeadlessMode).mockReturnValue(false); // Interactive const settings = createTestMergedSettings(); const argv = { @@ -194,7 +198,7 @@ describe('Workspace-Level Policy CLI Integration', () => { MOCK_CWD, 'new-hash', ); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), @@ -214,7 +218,7 @@ describe('Workspace-Level Policy CLI Integration', () => { hash: 'new-hash', fileCount: 5, }); - vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + vi.mocked(isHeadlessMode).mockReturnValue(false); // Interactive const settings = createTestMergedSettings(); const argv = { query: 'test' } as unknown as CliArgs; @@ -230,7 +234,7 @@ describe('Workspace-Level Policy CLI Integration', () => { 'new-hash', ); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: expect.stringContaining( path.join('.gemini', 'policies'), @@ -255,7 +259,7 @@ describe('Workspace-Level Policy CLI Integration', () => { hash: 'new-hash', fileCount: 1, }); - vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + vi.mocked(isHeadlessMode).mockReturnValue(false); // Interactive const settings = createTestMergedSettings(); const argv = { @@ -273,7 +277,7 @@ describe('Workspace-Level Policy CLI Integration', () => { policyDir: expect.stringContaining(path.join('.gemini', 'policies')), newHash: 'new-hash', }); - expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect(createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ workspacePoliciesDir: undefined, }), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fa0a293916..556608c759 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1148,6 +1148,8 @@ Logging in with Google... Restarting Gemini CLI to continue. activeBackgroundShellPid, setIsBackgroundShellListOpen, isBackgroundShellListOpen, + isBackgroundShellFullscreen, + setIsBackgroundShellFullscreen, setActiveBackgroundShellPid, backgroundShellHeight, } = useBackgroundShellManager({ @@ -1160,6 +1162,9 @@ Logging in with Google... Restarting Gemini CLI to continue. terminalHeight, }); + const [isForegroundShellFullscreen, setIsForegroundShellFullscreen] = + useState(false); + setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen; const lastOutputTimeRef = useRef(0); @@ -1840,6 +1845,29 @@ Logging in with Google... Restarting Gemini CLI to continue. setIsBackgroundShellListOpen(true); } return true; + } else if (keyMatchers[Command.TOGGLE_SHELL_FULLSCREEN](key)) { + if ( + settings.merged.experimental.fullscreen && + backgroundShells.size > 0 && + isBackgroundShellVisible + ) { + setIsBackgroundShellFullscreen((prev) => { + const newValue = !prev; + if (newValue) { + setEmbeddedShellFocused(true); + } + return newValue; + }); + } else if (settings.merged.experimental.fullscreen && activePtyId) { + setIsForegroundShellFullscreen((prev) => { + const newValue = !prev; + if (newValue) { + setEmbeddedShellFocused(true); + } + return newValue; + }); + } + return true; } return false; }, @@ -1870,7 +1898,10 @@ Logging in with Google... Restarting Gemini CLI to continue. lastOutputTimeRef, showTransientMessage, settings.merged.general.devtools, + settings.merged.experimental.fullscreen, showErrorDetails, + setIsBackgroundShellFullscreen, + setIsForegroundShellFullscreen, triggerExpandHint, keyMatchers, isHelpDismissKey, @@ -2298,6 +2329,8 @@ Logging in with Google... Restarting Gemini CLI to continue. backgroundShells, activeBackgroundShellPid, backgroundShellHeight, + isBackgroundShellFullscreen, + isForegroundShellFullscreen, isBackgroundShellListOpen, adminSettingsChanged, newAgents, @@ -2424,6 +2457,8 @@ Logging in with Google... Restarting Gemini CLI to continue. config, settingsNonce, backgroundShellHeight, + isBackgroundShellFullscreen, + isForegroundShellFullscreen, isBackgroundShellListOpen, activeBackgroundShellPid, backgroundShells, diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx index bb4c1f26da..71010e9fa0 100644 --- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx +++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx @@ -33,6 +33,7 @@ import { type RadioSelectItem, } from './shared/RadioButtonSelect.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; +import { useSettings } from '../contexts/SettingsContext.js'; interface BackgroundShellDisplayProps { shells: Map; @@ -70,6 +71,7 @@ export const BackgroundShellDisplay = ({ isListOpenProp, }: BackgroundShellDisplayProps) => { const keyMatchers = useKeyMatchers(); + const settings = useSettings(); const { dismissBackgroundShell, setActiveBackgroundShellPid, @@ -178,6 +180,10 @@ export const BackgroundShellDisplay = ({ return false; } + if (keyMatchers[Command.TOGGLE_SHELL_FULLSCREEN](key)) { + return false; + } + if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { void dismissBackgroundShell(activeShell.pid); return true; @@ -207,6 +213,9 @@ export const BackgroundShellDisplay = ({ { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL }, { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL }, { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST }, + ...(settings.merged.experimental.fullscreen + ? [{ label: 'Fullscreen', command: Command.TOGGLE_SHELL_FULLSCREEN }] + : []), ]; const helpTextStr = helpTextParts diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 9c8d90cd19..e04784b669 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -48,6 +48,7 @@ interface HistoryItemDisplayProps { isExpandable?: boolean; isFirstThinking?: boolean; isFirstAfterThinking?: boolean; + isFullscreen?: boolean; } export const HistoryItemDisplay: React.FC = ({ @@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC = ({ isExpandable, isFirstThinking = false, isFirstAfterThinking = false, + isFullscreen = false, }) => { const settings = useSettings(); const inlineThinkingMode = getInlineThinkingMode(settings); @@ -73,7 +75,8 @@ export const HistoryItemDisplay: React.FC = ({ flexDirection="column" key={itemForDisplay.id} width={terminalWidth} - marginTop={needsTopMarginAfterThinking ? 1 : 0} + marginTop={isFullscreen ? 0 : needsTopMarginAfterThinking ? 1 : 0} + paddingTop={isFullscreen ? 1 : 0} > {/* Render standard message types */} {itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && ( @@ -197,9 +200,10 @@ export const HistoryItemDisplay: React.FC = ({ toolCalls={itemForDisplay.tools} availableTerminalHeight={availableTerminalHeight} terminalWidth={terminalWidth} - borderTop={itemForDisplay.borderTop} - borderBottom={itemForDisplay.borderBottom} + borderTop={isFullscreen ? true : itemForDisplay.borderTop} + borderBottom={isFullscreen ? true : itemForDisplay.borderBottom} isExpandable={isExpandable} + isFullscreen={isFullscreen} /> )} {itemForDisplay.type === 'compression' && ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index d7e04bd351..54900d4db1 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -49,9 +49,14 @@ export const MainContent = () => { mainAreaWidth, staticAreaMaxItemHeight, cleanUiDetailsVisible, + isForegroundShellFullscreen, + terminalHeight, + activePtyId, } = uiState; const showHeaderDetails = cleanUiDetailsVisible; + const fullscreenHeight = Math.max(terminalHeight - 7, 5); + const lastUserPromptIndex = useMemo(() => { for (let i = uiState.history.length - 1; i >= 0; i--) { const type = uiState.history[i].type; @@ -90,9 +95,11 @@ export const MainContent = () => { { isExpandable={isExpandable} isFirstThinking={isFirstThinking} isFirstAfterThinking={isFirstAfterThinking} + isFullscreen={isForegroundShellFullscreen} /> ), ), @@ -111,6 +119,8 @@ export const MainContent = () => { staticAreaMaxItemHeight, uiState.slashCommands, uiState.constrainHeight, + isForegroundShellFullscreen, + fullscreenHeight, ], ); @@ -141,7 +151,11 @@ export const MainContent = () => { { isExpandable={true} isFirstThinking={isFirstThinking} isFirstAfterThinking={isFirstAfterThinking} + isFullscreen={isForegroundShellFullscreen} /> ); })} @@ -165,11 +180,41 @@ export const MainContent = () => { showConfirmationQueue, confirmingTool, uiState.history, + isForegroundShellFullscreen, + fullscreenHeight, ], ); - const virtualizedData = useMemo( - () => [ + const virtualizedData = useMemo(() => { + if (isForegroundShellFullscreen && activePtyId) { + // Find the item that contains the active PTY + const historyItem = uiState.history.find( + (h) => + h.type === 'tool_group' && + h.tools.some((t) => t.ptyId === activePtyId), + ); + if (historyItem) { + return [ + { + type: 'history' as const, + item: historyItem, + isExpandable: true, + isFirstThinking: false, + isFirstAfterThinking: false, + }, + ]; + } + const pendingItem = pendingHistoryItems.find( + (h) => + h.type === 'tool_group' && + h.tools.some((t) => t.ptyId === activePtyId), + ); + if (pendingItem) { + return [{ type: 'pending' as const }]; + } + } + + return [ { type: 'header' as const }, ...augmentedHistory.map( ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({ @@ -181,9 +226,14 @@ export const MainContent = () => { }), ), { type: 'pending' as const }, - ], - [augmentedHistory], - ); + ]; + }, [ + augmentedHistory, + isForegroundShellFullscreen, + activePtyId, + uiState.history, + pendingHistoryItems, + ]); const renderItem = useCallback( ({ item }: { item: (typeof virtualizedData)[number] }) => { @@ -200,9 +250,11 @@ export const MainContent = () => { { isExpandable={item.isExpandable} isFirstThinking={item.isFirstThinking} isFirstAfterThinking={item.isFirstAfterThinking} + isFullscreen={isForegroundShellFullscreen} /> ); } else { + if (isForegroundShellFullscreen && activePtyId) { + const pendingItem = pendingHistoryItems.find( + (h) => + h.type === 'tool_group' && + h.tools.some((t) => t.ptyId === activePtyId), + ); + if (pendingItem) { + return ( + + + + ); + } + } return pendingItems; } }, @@ -226,9 +303,55 @@ export const MainContent = () => { pendingItems, uiState.constrainHeight, staticAreaMaxItemHeight, + isForegroundShellFullscreen, + fullscreenHeight, + activePtyId, + pendingHistoryItems, ], ); + if (isForegroundShellFullscreen && activePtyId) { + const historyItem = uiState.history.find( + (h) => + h.type === 'tool_group' && h.tools.some((t) => t.ptyId === activePtyId), + ); + if (historyItem) { + return ( + + + + ); + } + const pendingItem = pendingHistoryItems.find( + (h) => + h.type === 'tool_group' && h.tools.some((t) => t.ptyId === activePtyId), + ); + if (pendingItem) { + return ( + + + + ); + } + } + if (isAlternateBuffer) { return ( = ({ name, - description, - resultDisplay, - status, - availableTerminalHeight, - terminalWidth, - emphasis = 'medium', - renderOutputAsMarkdown = true, - ptyId, - config, - isFirst, - borderColor, - borderDimColor, - isExpandable, - + isFullscreen, originalRequestName, }) => { const { @@ -93,14 +81,18 @@ export const ShellToolMessage: React.FC = ({ availableTerminalHeight, constrainHeight, isExpandable, + isFullscreen, }); const availableHeight = calculateToolContentMaxLines({ availableTerminalHeight, isAlternateBuffer, maxLinesLimit: maxLines, + isFullscreen, }); + const lastDimensionsRef = React.useRef({ width: 0, height: 0 }); + React.useEffect(() => { const isExecuting = status === CoreToolCallStatus.Executing; if (isExecuting && ptyId) { @@ -109,11 +101,20 @@ export const ShellToolMessage: React.FC = ({ const finalHeight = availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD; - ShellExecutionService.resizePty( - ptyId, - Math.max(1, childWidth), - Math.max(1, finalHeight), - ); + if ( + lastDimensionsRef.current.width !== childWidth || + lastDimensionsRef.current.height !== finalHeight + ) { + ShellExecutionService.resizePty( + ptyId, + Math.max(1, childWidth), + Math.max(1, finalHeight), + ); + lastDimensionsRef.current = { + width: childWidth, + height: finalHeight, + }; + } } catch (e) { if ( !( @@ -142,11 +143,8 @@ export const ShellToolMessage: React.FC = ({ }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]); const headerRef = React.useRef(null); - const contentRef = React.useRef(null); - // The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled. - const isThisShellFocusable = checkIsShellFocusable(name, status, config); const handleFocus = () => { @@ -156,7 +154,6 @@ export const ShellToolMessage: React.FC = ({ }; useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable }); - useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable }); const { shouldShowFocusHint } = useFocusHint( @@ -169,7 +166,7 @@ export const ShellToolMessage: React.FC = ({ <> = ({ renderOutputAsMarkdown={renderOutputAsMarkdown} hasFocus={isThisShellFocused} maxLines={maxLines} + isFullscreen={isFullscreen} /> {isThisShellFocused && config && ( = ({ borderTop: borderTopOverride, borderBottom: borderBottomOverride, isExpandable, + isFullscreen, }) => { const settings = useSettings(); const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full'; @@ -140,7 +142,8 @@ export const ToolGroupMessage: React.FC = ({ ) : undefined; - const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN; + const horizontalMargin = TOOL_MESSAGE_HORIZONTAL_MARGIN; + const contentWidth = terminalWidth - horizontalMargin; // If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity // internal errors, plan-mode hidden write/edit), we should not emit standalone @@ -164,7 +167,7 @@ export const ToolGroupMessage: React.FC = ({ cause tearing. */ width={terminalWidth} - paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} + paddingRight={horizontalMargin} > {visibleToolCalls.map((tool, index) => { const isFirst = index === 0; @@ -182,6 +185,7 @@ export const ToolGroupMessage: React.FC = ({ borderColor, borderDimColor, isExpandable, + isFullscreen, }; return ( @@ -226,7 +230,7 @@ export const ToolGroupMessage: React.FC = ({ */ (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( = ({ diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx index 0bbe3446e0..cc69c46855 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplay.tsx @@ -34,6 +34,7 @@ export interface ToolResultDisplayProps { maxLines?: number; hasFocus?: boolean; overflowDirection?: 'top' | 'bottom'; + isFullscreen?: boolean; } interface FileDiffResult { @@ -49,6 +50,7 @@ export const ToolResultDisplay: React.FC = ({ maxLines, hasFocus = false, overflowDirection = 'top', + isFullscreen = false, }) => { const { renderMarkdown } = useUIState(); const isAlternateBuffer = useAlternateBuffer(); @@ -57,6 +59,7 @@ export const ToolResultDisplay: React.FC = ({ availableTerminalHeight, isAlternateBuffer, maxLinesLimit: maxLines, + isFullscreen, }); const combinedPaddingAndBorderWidth = 4; @@ -173,11 +176,13 @@ export const ToolResultDisplay: React.FC = ({ // Virtualized path for large ANSI arrays if (Array.isArray(resultDisplay)) { const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES; - const listHeight = Math.min( - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - (resultDisplay as AnsiOutput).length, - limit, - ); + const listHeight = isFullscreen + ? limit + : Math.min( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (resultDisplay as AnsiOutput).length, + limit, + ); return ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index ea9025aa6b..626d133815 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -217,6 +217,8 @@ export interface UIState { backgroundShells: Map; activeBackgroundShellPid: number | null; backgroundShellHeight: number; + isBackgroundShellFullscreen: boolean; + isForegroundShellFullscreen: boolean; isBackgroundShellListOpen: boolean; adminSettingsChanged: boolean; newAgents: AgentDefinition[] | null; diff --git a/packages/cli/src/ui/hooks/useBackgroundShellManager.ts b/packages/cli/src/ui/hooks/useBackgroundShellManager.ts index 465e4b8e0d..23d6bb45b9 100644 --- a/packages/cli/src/ui/hooks/useBackgroundShellManager.ts +++ b/packages/cli/src/ui/hooks/useBackgroundShellManager.ts @@ -28,6 +28,8 @@ export function useBackgroundShellManager({ }: BackgroundShellManagerProps) { const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] = useState(false); + const [isBackgroundShellFullscreen, setIsBackgroundShellFullscreen] = + useState(false); const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState< number | null >(null); @@ -73,17 +75,27 @@ export function useBackgroundShellManager({ setEmbeddedShellFocused, ]); - const backgroundShellHeight = useMemo( - () => - isBackgroundShellVisible && backgroundShells.size > 0 - ? Math.max(Math.floor(terminalHeight * 0.3), 5) - : 0, - [isBackgroundShellVisible, backgroundShells.size, terminalHeight], - ); + const backgroundShellHeight = useMemo(() => { + if (!isBackgroundShellVisible || backgroundShells.size === 0) { + return 0; + } + if (isBackgroundShellFullscreen) { + // Leave enough room for the footer/composer (approx 7 lines) + return Math.max(terminalHeight - 7, 5); + } + return Math.max(Math.floor(terminalHeight * 0.3), 5); + }, [ + isBackgroundShellVisible, + backgroundShells.size, + terminalHeight, + isBackgroundShellFullscreen, + ]); return { isBackgroundShellListOpen, setIsBackgroundShellListOpen, + isBackgroundShellFullscreen, + setIsBackgroundShellFullscreen, activeBackgroundShellPid, setActiveBackgroundShellPid, backgroundShellHeight, diff --git a/packages/cli/src/ui/key/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts index 5b1afc0735..a80d7286c9 100644 --- a/packages/cli/src/ui/key/keyBindings.ts +++ b/packages/cli/src/ui/key/keyBindings.ts @@ -100,6 +100,7 @@ export enum Command { BACKGROUND_SHELL_SELECT = 'background.select', TOGGLE_BACKGROUND_SHELL = 'background.toggle', TOGGLE_BACKGROUND_SHELL_LIST = 'background.toggleList', + TOGGLE_SHELL_FULLSCREEN = 'shell.toggleFullscreen', KILL_BACKGROUND_SHELL = 'background.kill', UNFOCUS_BACKGROUND_SHELL = 'background.unfocus', UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList', @@ -395,6 +396,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([ [Command.BACKGROUND_SHELL_SELECT, [new KeyBinding('enter')]], [Command.TOGGLE_BACKGROUND_SHELL, [new KeyBinding('ctrl+b')]], [Command.TOGGLE_BACKGROUND_SHELL_LIST, [new KeyBinding('ctrl+l')]], + [Command.TOGGLE_SHELL_FULLSCREEN, [new KeyBinding('ctrl+7')]], [Command.KILL_BACKGROUND_SHELL, [new KeyBinding('ctrl+k')]], [Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]], [Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]], @@ -519,6 +521,7 @@ export const commandCategories: readonly CommandCategory[] = [ Command.BACKGROUND_SHELL_SELECT, Command.TOGGLE_BACKGROUND_SHELL, Command.TOGGLE_BACKGROUND_SHELL_LIST, + Command.TOGGLE_SHELL_FULLSCREEN, Command.KILL_BACKGROUND_SHELL, Command.UNFOCUS_BACKGROUND_SHELL, Command.UNFOCUS_BACKGROUND_SHELL_LIST, @@ -625,6 +628,8 @@ export const commandDescriptions: Readonly> = { [Command.TOGGLE_BACKGROUND_SHELL]: 'Toggle current background shell visibility.', [Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.', + [Command.TOGGLE_SHELL_FULLSCREEN]: + 'Toggle fullscreen mode for the active integrated or background shell.', [Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.', [Command.UNFOCUS_BACKGROUND_SHELL]: 'Move focus from background shell to Gemini.', diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index c703f5102f..0b80912fb9 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -39,9 +39,12 @@ export const DefaultAppLayout: React.FC = () => { overflow="hidden" ref={uiState.rootUiRef} > - + + + {uiState.isBackgroundShellVisible && + !uiState.isForegroundShellFullscreen && uiState.backgroundShells.size > 0 && uiState.activeBackgroundShellPid && uiState.backgroundShellHeight > 0 && diff --git a/packages/cli/src/ui/utils/toolLayoutUtils.ts b/packages/cli/src/ui/utils/toolLayoutUtils.ts index c91919cffa..d23a7e4172 100644 --- a/packages/cli/src/ui/utils/toolLayoutUtils.ts +++ b/packages/cli/src/ui/utils/toolLayoutUtils.ts @@ -39,8 +39,14 @@ export function calculateToolContentMaxLines(options: { availableTerminalHeight: number | undefined; isAlternateBuffer: boolean; maxLinesLimit?: number; + isFullscreen?: boolean; }): number | undefined { - const { availableTerminalHeight, isAlternateBuffer, maxLinesLimit } = options; + const { + availableTerminalHeight, + isAlternateBuffer, + maxLinesLimit, + isFullscreen, + } = options; const reservedLines = isAlternateBuffer ? TOOL_RESULT_ASB_RESERVED_LINE_COUNT @@ -48,7 +54,8 @@ export function calculateToolContentMaxLines(options: { let contentHeight = availableTerminalHeight ? Math.max( - availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines, + availableTerminalHeight - + (isFullscreen ? 3 : TOOL_RESULT_STATIC_HEIGHT + reservedLines), TOOL_RESULT_MIN_LINES_SHOWN + 1, ) : undefined; @@ -78,6 +85,7 @@ export function calculateShellMaxLines(options: { availableTerminalHeight: number | undefined; constrainHeight: boolean; isExpandable: boolean | undefined; + isFullscreen?: boolean; }): number | undefined { const { status, @@ -86,6 +94,7 @@ export function calculateShellMaxLines(options: { availableTerminalHeight, constrainHeight, isExpandable, + isFullscreen, } = options; // 1. If the user explicitly requested expansion (unconstrained), remove all caps. @@ -102,7 +111,12 @@ export function calculateShellMaxLines(options: { const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2); - // 3. Handle ASB mode focus expansion. + // 3. Handle Fullscreen or ASB mode focus expansion. + // Fullscreen mode always takes the full available height. + if (isFullscreen && isThisShellFocused) { + return maxLinesBasedOnHeight; + } + // We allow a focused shell in ASB mode to take up the full available height, // BUT only if we aren't trying to maintain a constrained view (e.g., history items). if (isAlternateBuffer && isThisShellFocused && !constrainHeight) {