Compare commits

...

2 Commits

Author SHA1 Message Date
matt korwel bb1ff6eb20 Update packages/cli/src/ui/components/MainContent.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-17 10:28:01 -07:00
mkorwel ade6dc0cc7 feat(ui): add fullscreen toggle for integrated and background shells 2026-03-16 01:04:36 -07:00
18 changed files with 361 additions and 119 deletions
@@ -16,7 +16,11 @@ import {
} from 'vitest'; } from 'vitest';
import { handleInstall, installCommand } from './install.js'; import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs'; import yargs from 'yargs';
import * as core from '@google/gemini-cli-core'; import {
debugLogger,
type FolderTrustDiscoveryService,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { import {
ExtensionManager, ExtensionManager,
type inferInstallMetadata, type inferInstallMetadata,
@@ -52,7 +56,7 @@ const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() => const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(), vi.fn(),
); );
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> = const mockDiscover: Mock<typeof FolderTrustDiscoveryService.discover> =
vi.hoisted(() => vi.fn()); vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({ vi.mock('../../config/extensions/consent.js', () => ({
@@ -117,8 +121,8 @@ describe('handleInstall', () => {
let processSpy: MockInstance; let processSpy: MockInstance;
beforeEach(() => { beforeEach(() => {
debugLogSpy = vi.spyOn(core.debugLogger, 'log'); debugLogSpy = vi.spyOn(debugLogger, 'log');
debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); debugErrorSpy = vi.spyOn(debugLogger, 'error');
processSpy = vi processSpy = vi
.spyOn(process, 'exit') .spyOn(process, 'exit')
.mockImplementation(() => undefined as never); .mockImplementation(() => undefined as never);
@@ -171,8 +175,8 @@ describe('handleInstall', () => {
}); });
function createMockExtension( function createMockExtension(
overrides: Partial<core.GeminiCLIExtension> = {}, overrides: Partial<GeminiCLIExtension> = {},
): core.GeminiCLIExtension { ): GeminiCLIExtension {
return { return {
name: 'mock-extension', name: 'mock-extension',
version: '1.0.0', version: '1.0.0',
+38 -38
View File
@@ -21,6 +21,11 @@ import {
type MCPServerConfig, type MCPServerConfig,
type GeminiCLIExtension, type GeminiCLIExtension,
Storage, Storage,
PolicyDecision,
TelemetryTarget,
loadServerHierarchicalMemory,
createPolicyEngineConfig,
type Config as CoreConfig,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js'; import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import { import {
@@ -28,7 +33,6 @@ import {
type MergedSettings, type MergedSettings,
createTestMergedSettings, createTestMergedSettings,
} from './settings.js'; } from './settings.js';
import * as ServerConfig from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from './trustedFolders.js'; import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionManager } from './extension-manager.js'; import { ExtensionManager } from './extension-manager.js';
@@ -99,9 +103,9 @@ vi.mock('read-package-up', () => ({
})); }));
vi.mock('@google/gemini-cli-core', async () => { vi.mock('@google/gemini-cli-core', async () => {
const actualServer = await vi.importActual<typeof ServerConfig>( const actualServer = await vi.importActual<
'@google/gemini-cli-core', typeof import('@google/gemini-cli-core')
); >('@google/gemini-cli-core');
return { return {
...actualServer, ...actualServer,
IdeClient: { IdeClient: {
@@ -146,8 +150,8 @@ vi.mock('@google/gemini-cli-core', async () => {
createPolicyEngineConfig: vi.fn(async () => ({ createPolicyEngineConfig: vi.fn(async () => ({
rules: [], rules: [],
checkers: [], checkers: [],
defaultDecision: ServerConfig.PolicyDecision.ASK_USER, defaultDecision: PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT, approvalMode: ApprovalMode.DEFAULT,
})), })),
getAdminErrorMessage: vi.fn( getAdminErrorMessage: vi.fn(
(_feature) => (_feature) =>
@@ -846,7 +850,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
]); ]);
const argv = await parseArguments(createTestMergedSettings()); const argv = await parseArguments(createTestMergedSettings());
await loadCliConfig(settings, 'session-id', argv); await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect(loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
[], [],
expect.any(Object), expect.any(Object),
@@ -874,7 +878,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
const argv = await parseArguments(settings); const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv); await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect(loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
[includeDir], [includeDir],
expect.any(Object), expect.any(Object),
@@ -901,7 +905,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
const argv = await parseArguments(settings); const argv = await parseArguments(settings);
await loadCliConfig(settings, 'session-id', argv); await loadCliConfig(settings, 'session-id', argv);
expect(ServerConfig.loadServerHierarchicalMemory).toHaveBeenCalledWith( expect(loadServerHierarchicalMemory).toHaveBeenCalledWith(
expect.any(String), expect.any(String),
[], [],
expect.any(Object), expect.any(Object),
@@ -2500,7 +2504,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 () => { it('should set YOLO approval mode when --yolo flag is used', async () => {
@@ -2511,7 +2515,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 () => { it('should set YOLO approval mode when -y flag is used', async () => {
@@ -2522,7 +2526,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 () => { it('should set DEFAULT approval mode when --approval-mode=default', async () => {
@@ -2533,7 +2537,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 () => { it('should set AUTO_EDIT approval mode when --approval-mode=auto_edit', async () => {
@@ -2544,7 +2548,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 () => { it('should set YOLO approval mode when --approval-mode=yolo', async () => {
@@ -2555,7 +2559,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 () => { 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', 'test-session',
argv, 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 () => { it('should fall back to --yolo behavior when --approval-mode is not set', async () => {
@@ -2581,7 +2585,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 () => { 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); 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 () => { 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 argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv); 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 () => { 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', 'test-session',
argv, argv,
); );
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
}); });
it('should override --approval-mode=auto_edit to DEFAULT', async () => { it('should override --approval-mode=auto_edit to DEFAULT', async () => {
@@ -2674,7 +2678,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, argv,
); );
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
}); });
it('should override --yolo flag to DEFAULT', async () => { it('should override --yolo flag to DEFAULT', async () => {
@@ -2685,7 +2689,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, argv,
); );
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT); expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
}); });
it('should remain DEFAULT when --approval-mode=default', async () => { it('should remain DEFAULT when --approval-mode=default', async () => {
@@ -2696,7 +2700,7 @@ describe('loadCliConfig approval mode', () => {
'test-session', 'test-session',
argv, 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 argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv); const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe( expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT);
ServerConfig.ApprovalMode.AUTO_EDIT,
);
}); });
it('should prioritize --approval-mode flag over settings', async () => { it('should prioritize --approval-mode flag over settings', async () => {
@@ -2720,9 +2722,7 @@ describe('loadCliConfig approval mode', () => {
}); });
const argv = await parseArguments(settings); const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv); const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe( expect(config.getApprovalMode()).toBe(ApprovalMode.AUTO_EDIT);
ServerConfig.ApprovalMode.AUTO_EDIT,
);
}); });
it('should prioritize --yolo flag over settings', async () => { it('should prioritize --yolo flag over settings', async () => {
@@ -2732,7 +2732,7 @@ describe('loadCliConfig approval mode', () => {
}); });
const argv = await parseArguments(settings); const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv); 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 () => { 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 argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv); 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 () => { 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<{ const testCases: Array<{
property: keyof FileFilteringSettings; property: keyof FileFilteringSettings;
getter: (config: ServerConfig.Config) => boolean; getter: (config: CoreConfig) => boolean;
value: boolean; value: boolean;
}> = [ }> = [
{ {
@@ -3085,7 +3085,7 @@ describe('Telemetry configuration via environment variables', () => {
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings()); const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({ const settings = createTestMergedSettings({
telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, telemetry: { target: TelemetryTarget.LOCAL },
}); });
const config = await loadCliConfig(settings, 'test-session', argv); const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getTelemetryTarget()).toBe('gcp'); expect(config.getTelemetryTarget()).toBe('gcp');
@@ -3096,7 +3096,7 @@ describe('Telemetry configuration via environment variables', () => {
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings()); const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({ const settings = createTestMergedSettings({
telemetry: { target: ServerConfig.TelemetryTarget.GCP }, telemetry: { target: TelemetryTarget.GCP },
}); });
await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow( await expect(loadCliConfig(settings, 'test-session', argv)).rejects.toThrow(
/Invalid telemetry configuration: .*Invalid telemetry target/i, /Invalid telemetry configuration: .*Invalid telemetry target/i,
@@ -3174,7 +3174,7 @@ describe('Telemetry configuration via environment variables', () => {
process.argv = ['node', 'script.js']; process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings()); const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({ const settings = createTestMergedSettings({
telemetry: { target: ServerConfig.TelemetryTarget.LOCAL }, telemetry: { target: TelemetryTarget.LOCAL },
}); });
const config = await loadCliConfig(settings, 'test-session', argv); const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getTelemetryTarget()).toBe('local'); expect(config.getTelemetryTarget()).toBe('local');
@@ -3298,7 +3298,7 @@ describe('Policy Engine Integration in loadCliConfig', () => {
await loadCliConfig(settings, 'test-session', argv); await loadCliConfig(settings, 'test-session', argv);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
tools: expect.objectContaining({ tools: expect.objectContaining({
allowed: expect.arrayContaining(['cli-tool']), allowed: expect.arrayContaining(['cli-tool']),
@@ -3319,7 +3319,7 @@ describe('Policy Engine Integration in loadCliConfig', () => {
await loadCliConfig(settings, 'test-session', argv); await loadCliConfig(settings, 'test-session', argv);
// In non-interactive mode, only ask_user is excluded by default // In non-interactive mode, only ask_user is excluded by default
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
tools: expect.objectContaining({ tools: expect.objectContaining({
exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]), exclude: expect.arrayContaining([ASK_USER_TOOL_NAME]),
@@ -3341,7 +3341,7 @@ describe('Policy Engine Integration in loadCliConfig', () => {
await loadCliConfig(settings, 'test-session', argv); await loadCliConfig(settings, 'test-session', argv);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'], policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'],
}), }),
+9
View File
@@ -97,6 +97,7 @@ export interface CliArgs {
rawOutput: boolean | undefined; rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined; acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined; isCommand: boolean | undefined;
fullscreen?: boolean;
} }
/** /**
@@ -297,6 +298,10 @@ export async function parseArguments(
.option('accept-raw-output-risk', { .option('accept-raw-output-risk', {
type: 'boolean', type: 'boolean',
description: 'Suppress the security warning when using --raw-output.', description: 'Suppress the security warning when using --raw-output.',
})
.option('fullscreen', {
type: 'boolean',
description: 'Enable experimental fullscreen mode.',
}), }),
) )
// Register MCP subcommands // Register MCP subcommands
@@ -435,6 +440,10 @@ export async function loadCliConfig(
process.env['GEMINI_SANDBOX'] = 'true'; process.env['GEMINI_SANDBOX'] = 'true';
} }
if (argv.fullscreen !== undefined) {
settings.experimental.fullscreen = argv.fullscreen;
}
const memoryImportFormat = settings.context?.importFormat || 'tree'; const memoryImportFormat = settings.context?.importFormat || 'tree';
const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true; const includeDirectoryTree = settings.context?.includeDirectoryTree ?? true;
+10
View File
@@ -1928,6 +1928,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable Plan Mode.', description: 'Enable Plan Mode.',
showInDialog: true, 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: { taskTracker: {
type: 'boolean', type: 'boolean',
label: 'Task Tracker', label: 'Task Tracker',
@@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as path from 'node:path'; import * as path from 'node:path';
import { loadCliConfig, type CliArgs } from './config.js'; import { loadCliConfig, type CliArgs } from './config.js';
import { createTestMergedSettings } from './settings.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 { isWorkspaceTrusted } from './trustedFolders.js';
import * as Policy from './policy.js'; import * as Policy from './policy.js';
@@ -21,9 +25,9 @@ const mockCheckIntegrity = vi.fn();
const mockAcceptIntegrity = vi.fn(); const mockAcceptIntegrity = vi.fn();
vi.mock('@google/gemini-cli-core', async () => { vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual<typeof ServerConfig>( const actual = await vi.importActual<
'@google/gemini-cli-core', typeof import('@google/gemini-cli-core')
); >('@google/gemini-cli-core');
return { return {
...actual, ...actual,
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({ loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
@@ -61,11 +65,11 @@ describe('Workspace-Level Policy CLI Integration', () => {
hash: 'test-hash', hash: 'test-hash',
fileCount: 1, fileCount: 1,
}); });
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); vi.mocked(isHeadlessMode).mockReturnValue(false);
}); });
it('should have getWorkspacePoliciesDir on Storage class', () => { 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(storage.getWorkspacePoliciesDir).toBeDefined();
expect(typeof storage.getWorkspacePoliciesDir).toBe('function'); 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 }); await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspacePoliciesDir: expect.stringContaining( workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'), path.join('.gemini', 'policies'),
@@ -102,7 +106,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspacePoliciesDir: undefined, workspacePoliciesDir: undefined,
}), }),
@@ -126,7 +130,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD }); await loadCliConfig(settings, 'test-session', argv, { cwd: MOCK_CWD });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspacePoliciesDir: undefined, workspacePoliciesDir: undefined,
}), }),
@@ -144,7 +148,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
hash: 'new-hash', hash: 'new-hash',
fileCount: 1, fileCount: 1,
}); });
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(true); // Non-interactive vi.mocked(isHeadlessMode).mockReturnValue(true); // Non-interactive
const settings = createTestMergedSettings(); const settings = createTestMergedSettings();
const argv = { prompt: 'do something' } as unknown as CliArgs; const argv = { prompt: 'do something' } as unknown as CliArgs;
@@ -156,7 +160,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
MOCK_CWD, MOCK_CWD,
'new-hash', 'new-hash',
); );
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspacePoliciesDir: expect.stringContaining( workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'), path.join('.gemini', 'policies'),
@@ -176,7 +180,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
hash: 'new-hash', hash: 'new-hash',
fileCount: 1, fileCount: 1,
}); });
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive vi.mocked(isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings(); const settings = createTestMergedSettings();
const argv = { const argv = {
@@ -194,7 +198,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
MOCK_CWD, MOCK_CWD,
'new-hash', 'new-hash',
); );
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspacePoliciesDir: expect.stringContaining( workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'), path.join('.gemini', 'policies'),
@@ -214,7 +218,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
hash: 'new-hash', hash: 'new-hash',
fileCount: 5, fileCount: 5,
}); });
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive vi.mocked(isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings(); const settings = createTestMergedSettings();
const argv = { query: 'test' } as unknown as CliArgs; const argv = { query: 'test' } as unknown as CliArgs;
@@ -230,7 +234,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
'new-hash', 'new-hash',
); );
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspacePoliciesDir: expect.stringContaining( workspacePoliciesDir: expect.stringContaining(
path.join('.gemini', 'policies'), path.join('.gemini', 'policies'),
@@ -255,7 +259,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
hash: 'new-hash', hash: 'new-hash',
fileCount: 1, fileCount: 1,
}); });
vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive vi.mocked(isHeadlessMode).mockReturnValue(false); // Interactive
const settings = createTestMergedSettings(); const settings = createTestMergedSettings();
const argv = { const argv = {
@@ -273,7 +277,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
policyDir: expect.stringContaining(path.join('.gemini', 'policies')), policyDir: expect.stringContaining(path.join('.gemini', 'policies')),
newHash: 'new-hash', newHash: 'new-hash',
}); });
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect(createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
workspacePoliciesDir: undefined, workspacePoliciesDir: undefined,
}), }),
+35
View File
@@ -1148,6 +1148,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
activeBackgroundShellPid, activeBackgroundShellPid,
setIsBackgroundShellListOpen, setIsBackgroundShellListOpen,
isBackgroundShellListOpen, isBackgroundShellListOpen,
isBackgroundShellFullscreen,
setIsBackgroundShellFullscreen,
setActiveBackgroundShellPid, setActiveBackgroundShellPid,
backgroundShellHeight, backgroundShellHeight,
} = useBackgroundShellManager({ } = useBackgroundShellManager({
@@ -1160,6 +1162,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalHeight, terminalHeight,
}); });
const [isForegroundShellFullscreen, setIsForegroundShellFullscreen] =
useState(false);
setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen; setIsBackgroundShellListOpenRef.current = setIsBackgroundShellListOpen;
const lastOutputTimeRef = useRef(0); const lastOutputTimeRef = useRef(0);
@@ -1840,6 +1845,29 @@ Logging in with Google... Restarting Gemini CLI to continue.
setIsBackgroundShellListOpen(true); setIsBackgroundShellListOpen(true);
} }
return 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; return false;
}, },
@@ -1870,7 +1898,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
lastOutputTimeRef, lastOutputTimeRef,
showTransientMessage, showTransientMessage,
settings.merged.general.devtools, settings.merged.general.devtools,
settings.merged.experimental.fullscreen,
showErrorDetails, showErrorDetails,
setIsBackgroundShellFullscreen,
setIsForegroundShellFullscreen,
triggerExpandHint, triggerExpandHint,
keyMatchers, keyMatchers,
isHelpDismissKey, isHelpDismissKey,
@@ -2298,6 +2329,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
backgroundShells, backgroundShells,
activeBackgroundShellPid, activeBackgroundShellPid,
backgroundShellHeight, backgroundShellHeight,
isBackgroundShellFullscreen,
isForegroundShellFullscreen,
isBackgroundShellListOpen, isBackgroundShellListOpen,
adminSettingsChanged, adminSettingsChanged,
newAgents, newAgents,
@@ -2424,6 +2457,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
config, config,
settingsNonce, settingsNonce,
backgroundShellHeight, backgroundShellHeight,
isBackgroundShellFullscreen,
isForegroundShellFullscreen,
isBackgroundShellListOpen, isBackgroundShellListOpen,
activeBackgroundShellPid, activeBackgroundShellPid,
backgroundShells, backgroundShells,
@@ -33,6 +33,7 @@ import {
type RadioSelectItem, type RadioSelectItem,
} from './shared/RadioButtonSelect.js'; } from './shared/RadioButtonSelect.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js'; import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface BackgroundShellDisplayProps { interface BackgroundShellDisplayProps {
shells: Map<number, BackgroundShell>; shells: Map<number, BackgroundShell>;
@@ -70,6 +71,7 @@ export const BackgroundShellDisplay = ({
isListOpenProp, isListOpenProp,
}: BackgroundShellDisplayProps) => { }: BackgroundShellDisplayProps) => {
const keyMatchers = useKeyMatchers(); const keyMatchers = useKeyMatchers();
const settings = useSettings();
const { const {
dismissBackgroundShell, dismissBackgroundShell,
setActiveBackgroundShellPid, setActiveBackgroundShellPid,
@@ -178,6 +180,10 @@ export const BackgroundShellDisplay = ({
return false; return false;
} }
if (keyMatchers[Command.TOGGLE_SHELL_FULLSCREEN](key)) {
return false;
}
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) { if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
void dismissBackgroundShell(activeShell.pid); void dismissBackgroundShell(activeShell.pid);
return true; return true;
@@ -207,6 +213,9 @@ export const BackgroundShellDisplay = ({
{ label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL }, { label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
{ label: 'Kill', command: Command.KILL_BACKGROUND_SHELL }, { label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
{ label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST }, { label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
...(settings.merged.experimental.fullscreen
? [{ label: 'Fullscreen', command: Command.TOGGLE_SHELL_FULLSCREEN }]
: []),
]; ];
const helpTextStr = helpTextParts const helpTextStr = helpTextParts
@@ -48,6 +48,7 @@ interface HistoryItemDisplayProps {
isExpandable?: boolean; isExpandable?: boolean;
isFirstThinking?: boolean; isFirstThinking?: boolean;
isFirstAfterThinking?: boolean; isFirstAfterThinking?: boolean;
isFullscreen?: boolean;
} }
export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
@@ -60,6 +61,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable, isExpandable,
isFirstThinking = false, isFirstThinking = false,
isFirstAfterThinking = false, isFirstAfterThinking = false,
isFullscreen = false,
}) => { }) => {
const settings = useSettings(); const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings); const inlineThinkingMode = getInlineThinkingMode(settings);
@@ -73,7 +75,8 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
flexDirection="column" flexDirection="column"
key={itemForDisplay.id} key={itemForDisplay.id}
width={terminalWidth} width={terminalWidth}
marginTop={needsTopMarginAfterThinking ? 1 : 0} marginTop={isFullscreen ? 0 : needsTopMarginAfterThinking ? 1 : 0}
paddingTop={isFullscreen ? 1 : 0}
> >
{/* Render standard message types */} {/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && ( {itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
@@ -197,9 +200,10 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
toolCalls={itemForDisplay.tools} toolCalls={itemForDisplay.tools}
availableTerminalHeight={availableTerminalHeight} availableTerminalHeight={availableTerminalHeight}
terminalWidth={terminalWidth} terminalWidth={terminalWidth}
borderTop={itemForDisplay.borderTop} borderTop={isFullscreen ? true : itemForDisplay.borderTop}
borderBottom={itemForDisplay.borderBottom} borderBottom={isFullscreen ? true : itemForDisplay.borderBottom}
isExpandable={isExpandable} isExpandable={isExpandable}
isFullscreen={isFullscreen}
/> />
)} )}
{itemForDisplay.type === 'compression' && ( {itemForDisplay.type === 'compression' && (
+135 -12
View File
@@ -49,9 +49,14 @@ export const MainContent = () => {
mainAreaWidth, mainAreaWidth,
staticAreaMaxItemHeight, staticAreaMaxItemHeight,
cleanUiDetailsVisible, cleanUiDetailsVisible,
isForegroundShellFullscreen,
terminalHeight,
activePtyId,
} = uiState; } = uiState;
const showHeaderDetails = cleanUiDetailsVisible; const showHeaderDetails = cleanUiDetailsVisible;
const fullscreenHeight = Math.max(terminalHeight - FOOTER_RESERVED_HEIGHT, 5);
const lastUserPromptIndex = useMemo(() => { const lastUserPromptIndex = useMemo(() => {
for (let i = uiState.history.length - 1; i >= 0; i--) { for (let i = uiState.history.length - 1; i >= 0; i--) {
const type = uiState.history[i].type; const type = uiState.history[i].type;
@@ -90,9 +95,11 @@ export const MainContent = () => {
<MemoizedHistoryItemDisplay <MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth} terminalWidth={mainAreaWidth}
availableTerminalHeight={ availableTerminalHeight={
uiState.constrainHeight || !isExpandable isForegroundShellFullscreen
? staticAreaMaxItemHeight ? fullscreenHeight
: undefined : uiState.constrainHeight || !isExpandable
? staticAreaMaxItemHeight
: undefined
} }
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES} availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.id} key={item.id}
@@ -102,6 +109,7 @@ export const MainContent = () => {
isExpandable={isExpandable} isExpandable={isExpandable}
isFirstThinking={isFirstThinking} isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking} isFirstAfterThinking={isFirstAfterThinking}
isFullscreen={isForegroundShellFullscreen}
/> />
), ),
), ),
@@ -111,6 +119,8 @@ export const MainContent = () => {
staticAreaMaxItemHeight, staticAreaMaxItemHeight,
uiState.slashCommands, uiState.slashCommands,
uiState.constrainHeight, uiState.constrainHeight,
isForegroundShellFullscreen,
fullscreenHeight,
], ],
); );
@@ -141,7 +151,11 @@ export const MainContent = () => {
<HistoryItemDisplay <HistoryItemDisplay
key={i} key={i}
availableTerminalHeight={ availableTerminalHeight={
uiState.constrainHeight ? staticAreaMaxItemHeight : undefined isForegroundShellFullscreen
? fullscreenHeight
: uiState.constrainHeight
? staticAreaMaxItemHeight
: undefined
} }
terminalWidth={mainAreaWidth} terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }} item={{ ...item, id: 0 }}
@@ -149,6 +163,7 @@ export const MainContent = () => {
isExpandable={true} isExpandable={true}
isFirstThinking={isFirstThinking} isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking} isFirstAfterThinking={isFirstAfterThinking}
isFullscreen={isForegroundShellFullscreen}
/> />
); );
})} })}
@@ -165,11 +180,41 @@ export const MainContent = () => {
showConfirmationQueue, showConfirmationQueue,
confirmingTool, confirmingTool,
uiState.history, 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 }, { type: 'header' as const },
...augmentedHistory.map( ...augmentedHistory.map(
({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({ ({ item, isExpandable, isFirstThinking, isFirstAfterThinking }) => ({
@@ -181,9 +226,14 @@ export const MainContent = () => {
}), }),
), ),
{ type: 'pending' as const }, { type: 'pending' as const },
], ];
[augmentedHistory], }, [
); augmentedHistory,
isForegroundShellFullscreen,
activePtyId,
uiState.history,
pendingHistoryItems,
]);
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: (typeof virtualizedData)[number] }) => { ({ item }: { item: (typeof virtualizedData)[number] }) => {
@@ -200,9 +250,11 @@ export const MainContent = () => {
<MemoizedHistoryItemDisplay <MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth} terminalWidth={mainAreaWidth}
availableTerminalHeight={ availableTerminalHeight={
uiState.constrainHeight || !item.isExpandable isForegroundShellFullscreen
? staticAreaMaxItemHeight ? fullscreenHeight
: undefined : uiState.constrainHeight || !item.isExpandable
? staticAreaMaxItemHeight
: undefined
} }
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES} availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.item.id} key={item.item.id}
@@ -212,9 +264,34 @@ export const MainContent = () => {
isExpandable={item.isExpandable} isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking} isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking} isFirstAfterThinking={item.isFirstAfterThinking}
isFullscreen={isForegroundShellFullscreen}
/> />
); );
} else { } else {
if (isForegroundShellFullscreen && activePtyId) {
const pendingItem = pendingHistoryItems.find(
(h) =>
h.type === 'tool_group' &&
h.tools.some((t) => t.ptyId === activePtyId),
);
if (pendingItem) {
return (
<Box flexDirection="column">
<HistoryItemDisplay
key={0}
availableTerminalHeight={fullscreenHeight}
terminalWidth={mainAreaWidth}
item={{ ...pendingItem, id: 0 }}
isPending={true}
isExpandable={true}
isFirstThinking={false}
isFirstAfterThinking={false}
isFullscreen={true}
/>
</Box>
);
}
}
return pendingItems; return pendingItems;
} }
}, },
@@ -226,9 +303,55 @@ export const MainContent = () => {
pendingItems, pendingItems,
uiState.constrainHeight, uiState.constrainHeight,
staticAreaMaxItemHeight, 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 (
<Box flexDirection="column" flexGrow={1} display="flex">
<HistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={fullscreenHeight}
key={historyItem.id}
item={historyItem}
isPending={false}
commands={uiState.slashCommands}
isExpandable={true}
isFullscreen={true}
/>
</Box>
);
}
const pendingItem = pendingHistoryItems.find(
(h) =>
h.type === 'tool_group' && h.tools.some((t) => t.ptyId === activePtyId),
);
if (pendingItem) {
return (
<Box flexDirection="column" flexGrow={1} display="flex">
<HistoryItemDisplay
key={0}
availableTerminalHeight={fullscreenHeight}
terminalWidth={mainAreaWidth}
item={{ ...pendingItem, id: 0 }}
isPending={true}
isExpandable={true}
isFullscreen={true}
/>
</Box>
);
}
}
if (isAlternateBuffer) { if (isAlternateBuffer) {
return ( return (
<ScrollableList <ScrollableList
@@ -38,37 +38,25 @@ import {
export interface ShellToolMessageProps extends ToolMessageProps { export interface ShellToolMessageProps extends ToolMessageProps {
config?: Config; config?: Config;
isExpandable?: boolean; isExpandable?: boolean;
isFullscreen?: boolean;
} }
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
name, name,
description, description,
resultDisplay, resultDisplay,
status, status,
availableTerminalHeight, availableTerminalHeight,
terminalWidth, terminalWidth,
emphasis = 'medium', emphasis = 'medium',
renderOutputAsMarkdown = true, renderOutputAsMarkdown = true,
ptyId, ptyId,
config, config,
isFirst, isFirst,
borderColor, borderColor,
borderDimColor, borderDimColor,
isExpandable, isExpandable,
isFullscreen,
originalRequestName, originalRequestName,
}) => { }) => {
const { const {
@@ -93,14 +81,18 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
availableTerminalHeight, availableTerminalHeight,
constrainHeight, constrainHeight,
isExpandable, isExpandable,
isFullscreen,
}); });
const availableHeight = calculateToolContentMaxLines({ const availableHeight = calculateToolContentMaxLines({
availableTerminalHeight, availableTerminalHeight,
isAlternateBuffer, isAlternateBuffer,
maxLinesLimit: maxLines, maxLinesLimit: maxLines,
isFullscreen,
}); });
const lastDimensionsRef = React.useRef({ width: 0, height: 0 });
React.useEffect(() => { React.useEffect(() => {
const isExecuting = status === CoreToolCallStatus.Executing; const isExecuting = status === CoreToolCallStatus.Executing;
if (isExecuting && ptyId) { if (isExecuting && ptyId) {
@@ -109,11 +101,20 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
const finalHeight = const finalHeight =
availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD; availableHeight ?? ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD;
ShellExecutionService.resizePty( if (
ptyId, lastDimensionsRef.current.width !== childWidth ||
Math.max(1, childWidth), lastDimensionsRef.current.height !== finalHeight
Math.max(1, finalHeight), ) {
); ShellExecutionService.resizePty(
ptyId,
Math.max(1, childWidth),
Math.max(1, finalHeight),
);
lastDimensionsRef.current = {
width: childWidth,
height: finalHeight,
};
}
} catch (e) { } catch (e) {
if ( if (
!( !(
@@ -142,11 +143,8 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]); }, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const headerRef = React.useRef<DOMElement>(null); const headerRef = React.useRef<DOMElement>(null);
const contentRef = React.useRef<DOMElement>(null); const contentRef = React.useRef<DOMElement>(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 isThisShellFocusable = checkIsShellFocusable(name, status, config);
const handleFocus = () => { const handleFocus = () => {
@@ -156,7 +154,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
}; };
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable }); useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable }); useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
const { shouldShowFocusHint } = useFocusHint( const { shouldShowFocusHint } = useFocusHint(
@@ -169,7 +166,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
<> <>
<StickyHeader <StickyHeader
width={terminalWidth} width={terminalWidth}
isFirst={isFirst} isFirst={isFullscreen ? true : isFirst}
borderColor={borderColor} borderColor={borderColor}
borderDimColor={borderDimColor} borderDimColor={borderDimColor}
containerRef={headerRef} containerRef={headerRef}
@@ -216,6 +213,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
renderOutputAsMarkdown={renderOutputAsMarkdown} renderOutputAsMarkdown={renderOutputAsMarkdown}
hasFocus={isThisShellFocused} hasFocus={isThisShellFocused}
maxLines={maxLines} maxLines={maxLines}
isFullscreen={isFullscreen}
/> />
{isThisShellFocused && config && ( {isThisShellFocused && config && (
<ShellInputPrompt <ShellInputPrompt
@@ -35,6 +35,7 @@ interface ToolGroupMessageProps {
borderTop?: boolean; borderTop?: boolean;
borderBottom?: boolean; borderBottom?: boolean;
isExpandable?: boolean; isExpandable?: boolean;
isFullscreen?: boolean;
} }
// Main component renders the border and maps the tools using ToolMessage // Main component renders the border and maps the tools using ToolMessage
@@ -48,6 +49,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderTop: borderTopOverride, borderTop: borderTopOverride,
borderBottom: borderBottomOverride, borderBottom: borderBottomOverride,
isExpandable, isExpandable,
isFullscreen,
}) => { }) => {
const settings = useSettings(); const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full'; const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
@@ -140,7 +142,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) )
: undefined; : 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 // 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 // internal errors, plan-mode hidden write/edit), we should not emit standalone
@@ -164,7 +167,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
cause tearing. cause tearing.
*/ */
width={terminalWidth} width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN} paddingRight={horizontalMargin}
> >
{visibleToolCalls.map((tool, index) => { {visibleToolCalls.map((tool, index) => {
const isFirst = index === 0; const isFirst = index === 0;
@@ -182,6 +185,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
borderColor, borderColor,
borderDimColor, borderDimColor,
isExpandable, isExpandable,
isFullscreen,
}; };
return ( return (
@@ -226,7 +230,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/ */
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
<Box <Box
height={0} height={isFullscreen ? 1 : 0}
width={contentWidth} width={contentWidth}
borderLeft={true} borderLeft={true}
borderRight={true} borderRight={true}
@@ -39,6 +39,7 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
embeddedShellFocused?: boolean; embeddedShellFocused?: boolean;
ptyId?: number; ptyId?: number;
config?: Config; config?: Config;
isFullscreen?: boolean;
} }
export const ToolMessage: React.FC<ToolMessageProps> = ({ export const ToolMessage: React.FC<ToolMessageProps> = ({
@@ -34,6 +34,7 @@ export interface ToolResultDisplayProps {
maxLines?: number; maxLines?: number;
hasFocus?: boolean; hasFocus?: boolean;
overflowDirection?: 'top' | 'bottom'; overflowDirection?: 'top' | 'bottom';
isFullscreen?: boolean;
} }
interface FileDiffResult { interface FileDiffResult {
@@ -49,6 +50,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
maxLines, maxLines,
hasFocus = false, hasFocus = false,
overflowDirection = 'top', overflowDirection = 'top',
isFullscreen = false,
}) => { }) => {
const { renderMarkdown } = useUIState(); const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer(); const isAlternateBuffer = useAlternateBuffer();
@@ -57,6 +59,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
availableTerminalHeight, availableTerminalHeight,
isAlternateBuffer, isAlternateBuffer,
maxLinesLimit: maxLines, maxLinesLimit: maxLines,
isFullscreen,
}); });
const combinedPaddingAndBorderWidth = 4; const combinedPaddingAndBorderWidth = 4;
@@ -173,11 +176,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
// Virtualized path for large ANSI arrays // Virtualized path for large ANSI arrays
if (Array.isArray(resultDisplay)) { if (Array.isArray(resultDisplay)) {
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES; const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min( const listHeight = isFullscreen
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion ? limit
(resultDisplay as AnsiOutput).length, : Math.min(
limit, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
); (resultDisplay as AnsiOutput).length,
limit,
);
return ( return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}> <Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
@@ -217,6 +217,8 @@ export interface UIState {
backgroundShells: Map<number, BackgroundShell>; backgroundShells: Map<number, BackgroundShell>;
activeBackgroundShellPid: number | null; activeBackgroundShellPid: number | null;
backgroundShellHeight: number; backgroundShellHeight: number;
isBackgroundShellFullscreen: boolean;
isForegroundShellFullscreen: boolean;
isBackgroundShellListOpen: boolean; isBackgroundShellListOpen: boolean;
adminSettingsChanged: boolean; adminSettingsChanged: boolean;
newAgents: AgentDefinition[] | null; newAgents: AgentDefinition[] | null;
@@ -28,6 +28,8 @@ export function useBackgroundShellManager({
}: BackgroundShellManagerProps) { }: BackgroundShellManagerProps) {
const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] = const [isBackgroundShellListOpen, setIsBackgroundShellListOpen] =
useState(false); useState(false);
const [isBackgroundShellFullscreen, setIsBackgroundShellFullscreen] =
useState(false);
const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState< const [activeBackgroundShellPid, setActiveBackgroundShellPid] = useState<
number | null number | null
>(null); >(null);
@@ -73,17 +75,27 @@ export function useBackgroundShellManager({
setEmbeddedShellFocused, setEmbeddedShellFocused,
]); ]);
const backgroundShellHeight = useMemo( const backgroundShellHeight = useMemo(() => {
() => if (!isBackgroundShellVisible || backgroundShells.size === 0) {
isBackgroundShellVisible && backgroundShells.size > 0 return 0;
? Math.max(Math.floor(terminalHeight * 0.3), 5) }
: 0, if (isBackgroundShellFullscreen) {
[isBackgroundShellVisible, backgroundShells.size, terminalHeight], // 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 { return {
isBackgroundShellListOpen, isBackgroundShellListOpen,
setIsBackgroundShellListOpen, setIsBackgroundShellListOpen,
isBackgroundShellFullscreen,
setIsBackgroundShellFullscreen,
activeBackgroundShellPid, activeBackgroundShellPid,
setActiveBackgroundShellPid, setActiveBackgroundShellPid,
backgroundShellHeight, backgroundShellHeight,
+5
View File
@@ -100,6 +100,7 @@ export enum Command {
BACKGROUND_SHELL_SELECT = 'background.select', BACKGROUND_SHELL_SELECT = 'background.select',
TOGGLE_BACKGROUND_SHELL = 'background.toggle', TOGGLE_BACKGROUND_SHELL = 'background.toggle',
TOGGLE_BACKGROUND_SHELL_LIST = 'background.toggleList', TOGGLE_BACKGROUND_SHELL_LIST = 'background.toggleList',
TOGGLE_SHELL_FULLSCREEN = 'shell.toggleFullscreen',
KILL_BACKGROUND_SHELL = 'background.kill', KILL_BACKGROUND_SHELL = 'background.kill',
UNFOCUS_BACKGROUND_SHELL = 'background.unfocus', UNFOCUS_BACKGROUND_SHELL = 'background.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList', UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList',
@@ -395,6 +396,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.BACKGROUND_SHELL_SELECT, [new KeyBinding('enter')]], [Command.BACKGROUND_SHELL_SELECT, [new KeyBinding('enter')]],
[Command.TOGGLE_BACKGROUND_SHELL, [new KeyBinding('ctrl+b')]], [Command.TOGGLE_BACKGROUND_SHELL, [new KeyBinding('ctrl+b')]],
[Command.TOGGLE_BACKGROUND_SHELL_LIST, [new KeyBinding('ctrl+l')]], [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.KILL_BACKGROUND_SHELL, [new KeyBinding('ctrl+k')]],
[Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]], [Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]], [Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
@@ -519,6 +521,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.BACKGROUND_SHELL_SELECT, Command.BACKGROUND_SHELL_SELECT,
Command.TOGGLE_BACKGROUND_SHELL, Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST, Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.TOGGLE_SHELL_FULLSCREEN,
Command.KILL_BACKGROUND_SHELL, Command.KILL_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL, Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST, Command.UNFOCUS_BACKGROUND_SHELL_LIST,
@@ -625,6 +628,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.TOGGLE_BACKGROUND_SHELL]: [Command.TOGGLE_BACKGROUND_SHELL]:
'Toggle current background shell visibility.', 'Toggle current background shell visibility.',
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.', [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.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
[Command.UNFOCUS_BACKGROUND_SHELL]: [Command.UNFOCUS_BACKGROUND_SHELL]:
'Move focus from background shell to Gemini.', 'Move focus from background shell to Gemini.',
@@ -39,9 +39,12 @@ export const DefaultAppLayout: React.FC = () => {
overflow="hidden" overflow="hidden"
ref={uiState.rootUiRef} ref={uiState.rootUiRef}
> >
<MainContent /> <Box flexGrow={1} flexDirection="column">
<MainContent />
</Box>
{uiState.isBackgroundShellVisible && {uiState.isBackgroundShellVisible &&
!uiState.isForegroundShellFullscreen &&
uiState.backgroundShells.size > 0 && uiState.backgroundShells.size > 0 &&
uiState.activeBackgroundShellPid && uiState.activeBackgroundShellPid &&
uiState.backgroundShellHeight > 0 && uiState.backgroundShellHeight > 0 &&
+17 -3
View File
@@ -39,8 +39,14 @@ export function calculateToolContentMaxLines(options: {
availableTerminalHeight: number | undefined; availableTerminalHeight: number | undefined;
isAlternateBuffer: boolean; isAlternateBuffer: boolean;
maxLinesLimit?: number; maxLinesLimit?: number;
isFullscreen?: boolean;
}): number | undefined { }): number | undefined {
const { availableTerminalHeight, isAlternateBuffer, maxLinesLimit } = options; const {
availableTerminalHeight,
isAlternateBuffer,
maxLinesLimit,
isFullscreen,
} = options;
const reservedLines = isAlternateBuffer const reservedLines = isAlternateBuffer
? TOOL_RESULT_ASB_RESERVED_LINE_COUNT ? TOOL_RESULT_ASB_RESERVED_LINE_COUNT
@@ -48,7 +54,8 @@ export function calculateToolContentMaxLines(options: {
let contentHeight = availableTerminalHeight let contentHeight = availableTerminalHeight
? Math.max( ? Math.max(
availableTerminalHeight - TOOL_RESULT_STATIC_HEIGHT - reservedLines, availableTerminalHeight -
(isFullscreen ? 3 : TOOL_RESULT_STATIC_HEIGHT + reservedLines),
TOOL_RESULT_MIN_LINES_SHOWN + 1, TOOL_RESULT_MIN_LINES_SHOWN + 1,
) )
: undefined; : undefined;
@@ -78,6 +85,7 @@ export function calculateShellMaxLines(options: {
availableTerminalHeight: number | undefined; availableTerminalHeight: number | undefined;
constrainHeight: boolean; constrainHeight: boolean;
isExpandable: boolean | undefined; isExpandable: boolean | undefined;
isFullscreen?: boolean;
}): number | undefined { }): number | undefined {
const { const {
status, status,
@@ -86,6 +94,7 @@ export function calculateShellMaxLines(options: {
availableTerminalHeight, availableTerminalHeight,
constrainHeight, constrainHeight,
isExpandable, isExpandable,
isFullscreen,
} = options; } = options;
// 1. If the user explicitly requested expansion (unconstrained), remove all caps. // 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); 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, // 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). // BUT only if we aren't trying to maintain a constrained view (e.g., history items).
if (isAlternateBuffer && isThisShellFocused && !constrainHeight) { if (isAlternateBuffer && isThisShellFocused && !constrainHeight) {