mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-08 16:11:58 -07:00
feat: Detect background color (#15132)
This commit is contained in:
@@ -127,11 +127,19 @@ vi.mock('./config/settings.js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({
|
||||||
|
terminalCapabilityManager: {
|
||||||
|
detectCapabilities: vi.fn(),
|
||||||
|
getTerminalBackgroundColor: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('./config/config.js', () => ({
|
vi.mock('./config/config.js', () => ({
|
||||||
loadCliConfig: vi.fn().mockResolvedValue({
|
loadCliConfig: vi.fn().mockResolvedValue({
|
||||||
getSandbox: vi.fn(() => false),
|
getSandbox: vi.fn(() => false),
|
||||||
getQuestion: vi.fn(() => ''),
|
getQuestion: vi.fn(() => ''),
|
||||||
isInteractive: () => false,
|
isInteractive: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as unknown as Config),
|
} as unknown as Config),
|
||||||
parseArguments: vi.fn().mockResolvedValue({}),
|
parseArguments: vi.fn().mockResolvedValue({}),
|
||||||
isDebugMode: vi.fn(() => false),
|
isDebugMode: vi.fn(() => false),
|
||||||
@@ -271,6 +279,7 @@ describe('gemini.tsx main function', () => {
|
|||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getExtensions: () => [],
|
getExtensions: () => [],
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
});
|
});
|
||||||
vi.mocked(loadSettings).mockReturnValue({
|
vi.mocked(loadSettings).mockReturnValue({
|
||||||
@@ -464,9 +473,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => {
|
it('should call setRawMode and detectCapabilities when isInteractive is true', async () => {
|
||||||
const { detectAndEnableKittyProtocol } = await import(
|
const { terminalCapabilityManager } = await import(
|
||||||
'./ui/utils/kittyProtocolDetector.js'
|
'./ui/utils/terminalCapabilityManager.js'
|
||||||
);
|
);
|
||||||
const { loadCliConfig, parseArguments } = await import(
|
const { loadCliConfig, parseArguments } = await import(
|
||||||
'./config/config.js'
|
'./config/config.js'
|
||||||
@@ -504,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getExtensions: () => [],
|
getExtensions: () => [],
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as unknown as Config);
|
} as unknown as Config);
|
||||||
vi.mocked(loadSettings).mockReturnValue({
|
vi.mocked(loadSettings).mockReturnValue({
|
||||||
errors: [],
|
errors: [],
|
||||||
@@ -546,7 +556,9 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(setRawModeSpy).toHaveBeenCalledWith(true);
|
expect(setRawModeSpy).toHaveBeenCalledWith(true);
|
||||||
expect(detectAndEnableKittyProtocol).toHaveBeenCalledTimes(1);
|
expect(terminalCapabilityManager.detectCapabilities).toHaveBeenCalledTimes(
|
||||||
|
1,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
@@ -601,6 +613,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
getScreenReader: () => false,
|
getScreenReader: () => false,
|
||||||
getGeminiMdFileCount: () => 0,
|
getGeminiMdFileCount: () => 0,
|
||||||
getProjectRoot: () => '/',
|
getProjectRoot: () => '/',
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||||
@@ -683,6 +696,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
getGeminiMdFileCount: () => 0,
|
getGeminiMdFileCount: () => 0,
|
||||||
getProjectRoot: () => '/',
|
getProjectRoot: () => '/',
|
||||||
refreshAuth: vi.fn(),
|
refreshAuth: vi.fn(),
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
vi.mocked(loadCliConfig).mockResolvedValue(mockConfig);
|
||||||
@@ -762,6 +776,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
getFileFilteringRespectGitIgnore: () => true,
|
getFileFilteringRespectGitIgnore: () => true,
|
||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
|
||||||
vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false);
|
vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false);
|
||||||
@@ -844,6 +859,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
getFileFilteringRespectGitIgnore: () => true,
|
getFileFilteringRespectGitIgnore: () => true,
|
||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -921,6 +937,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
getFileFilteringRespectGitIgnore: () => true,
|
getFileFilteringRespectGitIgnore: () => true,
|
||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
|
||||||
// The mock is already set up at the top of the test
|
// The mock is already set up at the top of the test
|
||||||
@@ -993,6 +1010,7 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||||||
getFileFilteringRespectGitIgnore: () => true,
|
getFileFilteringRespectGitIgnore: () => true,
|
||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
|
||||||
vi.mock('./utils/readStdin.js', () => ({
|
vi.mock('./utils/readStdin.js', () => ({
|
||||||
@@ -1152,6 +1170,7 @@ describe('gemini.tsx main function exit codes', () => {
|
|||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getExtensions: () => [],
|
getExtensions: () => [],
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as unknown as Config);
|
} as unknown as Config);
|
||||||
vi.mocked(loadSettings).mockReturnValue({
|
vi.mocked(loadSettings).mockReturnValue({
|
||||||
merged: { security: { auth: {} }, ui: {} },
|
merged: { security: { auth: {} }, ui: {} },
|
||||||
@@ -1214,6 +1233,7 @@ describe('gemini.tsx main function exit codes', () => {
|
|||||||
getOutputFormat: () => 'text',
|
getOutputFormat: () => 'text',
|
||||||
getExtensions: () => [],
|
getExtensions: () => [],
|
||||||
getUsageStatisticsEnabled: () => false,
|
getUsageStatisticsEnabled: () => false,
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as unknown as Config);
|
} as unknown as Config);
|
||||||
vi.mocked(loadSettings).mockReturnValue({
|
vi.mocked(loadSettings).mockReturnValue({
|
||||||
merged: { security: { auth: {} }, ui: {} },
|
merged: { security: { auth: {} }, ui: {} },
|
||||||
@@ -1295,11 +1315,6 @@ describe('startInteractiveUI', () => {
|
|||||||
geminiMdFileCount: 0,
|
geminiMdFileCount: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('./ui/utils/kittyProtocolDetector.js', () => ({
|
|
||||||
detectAndEnableKittyProtocol: vi.fn(() => Promise.resolve(true)),
|
|
||||||
isKittyProtocolSupported: vi.fn(() => true),
|
|
||||||
isKittyProtocolEnabled: vi.fn(() => true),
|
|
||||||
}));
|
|
||||||
vi.mock('./ui/utils/updateCheck.js', () => ({
|
vi.mock('./ui/utils/updateCheck.js', () => ({
|
||||||
checkForUpdates: vi.fn(() => Promise.resolve(null)),
|
checkForUpdates: vi.fn(() => Promise.resolve(null)),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
migrateDeprecatedSettings,
|
migrateDeprecatedSettings,
|
||||||
SettingScope,
|
SettingScope,
|
||||||
} from './config/settings.js';
|
} from './config/settings.js';
|
||||||
import { themeManager } from './ui/themes/theme-manager.js';
|
|
||||||
import { getStartupWarnings } from './utils/startupWarnings.js';
|
import { getStartupWarnings } from './utils/startupWarnings.js';
|
||||||
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
import { getUserStartupWarnings } from './utils/userStartupWarnings.js';
|
||||||
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
|
||||||
@@ -73,7 +72,6 @@ import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js';
|
|||||||
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
import { runZedIntegration } from './zed-integration/zedIntegration.js';
|
||||||
import { cleanupExpiredSessions } from './utils/sessionCleanup.js';
|
import { cleanupExpiredSessions } from './utils/sessionCleanup.js';
|
||||||
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
|
||||||
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
|
|
||||||
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
import { checkForUpdates } from './ui/utils/updateCheck.js';
|
||||||
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
import { handleAutoUpdate } from './utils/handleAutoUpdate.js';
|
||||||
import { appEvents, AppEvent } from './utils/events.js';
|
import { appEvents, AppEvent } from './utils/events.js';
|
||||||
@@ -98,6 +96,7 @@ import { requestConsentNonInteractive } from './config/extensions/consent.js';
|
|||||||
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
|
||||||
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
|
||||||
|
|
||||||
|
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
|
||||||
import { profiler } from './ui/components/DebugProfiler.js';
|
import { profiler } from './ui/components/DebugProfiler.js';
|
||||||
|
|
||||||
const SLOW_RENDER_MS = 200;
|
const SLOW_RENDER_MS = 200;
|
||||||
@@ -360,19 +359,6 @@ export async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load custom themes from settings
|
|
||||||
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
|
|
||||||
|
|
||||||
if (settings.merged.ui?.theme) {
|
|
||||||
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
|
|
||||||
// If the theme is not found during initial load, log a warning and continue.
|
|
||||||
// The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.
|
|
||||||
debugLogger.warn(
|
|
||||||
`Warning: Theme "${settings.merged.ui?.theme}" not found.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hop into sandbox if we are outside and sandboxing is enabled
|
// hop into sandbox if we are outside and sandboxing is enabled
|
||||||
if (!process.env['SANDBOX']) {
|
if (!process.env['SANDBOX']) {
|
||||||
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
|
const memoryArgs = settings.merged.advanced?.autoConfigureMemory
|
||||||
@@ -550,11 +536,10 @@ export async function main() {
|
|||||||
process.on('SIGINT', () => {
|
process.on('SIGINT', () => {
|
||||||
process.stdin.setRawMode(wasRaw);
|
process.stdin.setRawMode(wasRaw);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Detect and enable Kitty keyboard protocol once at startup.
|
|
||||||
await detectAndEnableKittyProtocol();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await setupTerminalAndTheme(config, settings);
|
||||||
|
|
||||||
setMaxSizedBoxDebugging(isDebugMode);
|
setMaxSizedBoxDebugging(isDebugMode);
|
||||||
const initAppHandle = startupProfiler.start('initialize_app');
|
const initAppHandle = startupProfiler.start('initialize_app');
|
||||||
const initializationResult = await initializeApp(config, settings);
|
const initializationResult = await initializeApp(config, settings);
|
||||||
|
|||||||
@@ -210,6 +210,7 @@ describe('gemini.tsx main function cleanup', () => {
|
|||||||
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
getFileFilteringRespectGitIgnore: vi.fn(() => true),
|
||||||
getOutputFormat: vi.fn(() => 'text'),
|
getOutputFormat: vi.fn(() => 'text'),
|
||||||
getUsageStatisticsEnabled: vi.fn(() => false),
|
getUsageStatisticsEnabled: vi.fn(() => false),
|
||||||
|
setTerminalBackground: vi.fn(),
|
||||||
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const baseMockUiState = {
|
|||||||
mainAreaWidth: 100,
|
mainAreaWidth: 100,
|
||||||
terminalWidth: 120,
|
terminalWidth: 120,
|
||||||
currentModel: 'gemini-pro',
|
currentModel: 'gemini-pro',
|
||||||
|
terminalBackgroundColor: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockUIActions: UIActions = {
|
const mockUIActions: UIActions = {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
|
|||||||
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
|
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
|
||||||
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
|
||||||
import { useSettings } from './contexts/SettingsContext.js';
|
import { useSettings } from './contexts/SettingsContext.js';
|
||||||
import { enableSupportedProtocol } from './utils/kittyProtocolDetector.js';
|
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
|
||||||
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
|
||||||
import { enableBracketedPaste } from './utils/bracketedPaste.js';
|
import { enableBracketedPaste } from './utils/bracketedPaste.js';
|
||||||
import { useBanner } from './hooks/useBanner.js';
|
import { useBanner } from './hooks/useBanner.js';
|
||||||
@@ -398,7 +398,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||||||
app.rerender();
|
app.rerender();
|
||||||
}
|
}
|
||||||
enableBracketedPaste();
|
enableBracketedPaste();
|
||||||
enableSupportedProtocol();
|
terminalCapabilityManager.enableKittyProtocol();
|
||||||
refreshStatic();
|
refreshStatic();
|
||||||
}, [refreshStatic, isAlternateBuffer, app, config]);
|
}, [refreshStatic, isAlternateBuffer, app, config]);
|
||||||
|
|
||||||
@@ -1529,6 +1529,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
warningMessage,
|
warningMessage,
|
||||||
bannerData,
|
bannerData,
|
||||||
bannerVisible,
|
bannerVisible,
|
||||||
|
terminalBackgroundColor: config.getTerminalBackground(),
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isThemeDialogOpen,
|
isThemeDialogOpen,
|
||||||
@@ -1620,6 +1621,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
|||||||
warningMessage,
|
warningMessage,
|
||||||
bannerData,
|
bannerData,
|
||||||
bannerVisible,
|
bannerVisible,
|
||||||
|
config,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ vi.mock('node:process', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../utils/terminalCapabilityManager.js', () => ({
|
||||||
|
terminalCapabilityManager: {
|
||||||
|
getTerminalName: vi.fn().mockReturnValue('Test Terminal'),
|
||||||
|
getTerminalBackgroundColor: vi.fn().mockReturnValue('#000000'),
|
||||||
|
isKittyProtocolEnabled: vi.fn().mockReturnValue(true),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('bugCommand', () => {
|
describe('bugCommand', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
vi.mocked(getVersion).mockResolvedValue('0.1.0');
|
||||||
@@ -73,6 +81,9 @@ describe('bugCommand', () => {
|
|||||||
* **Sandbox Environment:** test
|
* **Sandbox Environment:** test
|
||||||
* **Model Version:** gemini-pro
|
* **Model Version:** gemini-pro
|
||||||
* **Memory Usage:** 100 MB
|
* **Memory Usage:** 100 MB
|
||||||
|
* **Terminal Name:** Test Terminal
|
||||||
|
* **Terminal Background:** #000000
|
||||||
|
* **Kitty Keyboard Protocol:** Supported
|
||||||
* **IDE Client:** VSCode
|
* **IDE Client:** VSCode
|
||||||
`;
|
`;
|
||||||
const expectedUrl =
|
const expectedUrl =
|
||||||
@@ -106,6 +117,9 @@ describe('bugCommand', () => {
|
|||||||
* **Sandbox Environment:** test
|
* **Sandbox Environment:** test
|
||||||
* **Model Version:** gemini-pro
|
* **Model Version:** gemini-pro
|
||||||
* **Memory Usage:** 100 MB
|
* **Memory Usage:** 100 MB
|
||||||
|
* **Terminal Name:** Test Terminal
|
||||||
|
* **Terminal Background:** #000000
|
||||||
|
* **Kitty Keyboard Protocol:** Supported
|
||||||
* **IDE Client:** VSCode
|
* **IDE Client:** VSCode
|
||||||
`;
|
`;
|
||||||
const expectedUrl = customTemplate
|
const expectedUrl = customTemplate
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { MessageType } from '../types.js';
|
|||||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||||
import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core';
|
import { IdeClient, sessionId, getVersion } from '@google/gemini-cli-core';
|
||||||
|
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||||
|
|
||||||
export const bugCommand: SlashCommand = {
|
export const bugCommand: SlashCommand = {
|
||||||
name: 'bug',
|
name: 'bug',
|
||||||
@@ -38,6 +39,13 @@ export const bugCommand: SlashCommand = {
|
|||||||
const cliVersion = await getVersion();
|
const cliVersion = await getVersion();
|
||||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||||
const ideClient = await getIdeClientName(context);
|
const ideClient = await getIdeClientName(context);
|
||||||
|
const terminalName =
|
||||||
|
terminalCapabilityManager.getTerminalName() || 'Unknown';
|
||||||
|
const terminalBgColor =
|
||||||
|
terminalCapabilityManager.getTerminalBackgroundColor() || 'Unknown';
|
||||||
|
const kittyProtocol = terminalCapabilityManager.isKittyProtocolEnabled()
|
||||||
|
? 'Supported'
|
||||||
|
: 'Unsupported';
|
||||||
|
|
||||||
let info = `
|
let info = `
|
||||||
* **CLI Version:** ${cliVersion}
|
* **CLI Version:** ${cliVersion}
|
||||||
@@ -47,6 +55,9 @@ export const bugCommand: SlashCommand = {
|
|||||||
* **Sandbox Environment:** ${sandboxEnv}
|
* **Sandbox Environment:** ${sandboxEnv}
|
||||||
* **Model Version:** ${modelVersion}
|
* **Model Version:** ${modelVersion}
|
||||||
* **Memory Usage:** ${memoryUsage}
|
* **Memory Usage:** ${memoryUsage}
|
||||||
|
* **Terminal Name:** ${terminalName}
|
||||||
|
* **Terminal Background:** ${terminalBgColor}
|
||||||
|
* **Kitty Keyboard Protocol:** ${kittyProtocol}
|
||||||
`;
|
`;
|
||||||
if (ideClient) {
|
if (ideClient) {
|
||||||
info += `* **IDE Client:** ${ideClient}\n`;
|
info += `* **IDE Client:** ${ideClient}\n`;
|
||||||
|
|||||||
@@ -143,3 +143,96 @@ describe('ThemeDialog Snapshots', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Initial Theme Selection', () => {
|
||||||
|
const baseProps = {
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onHighlight: vi.fn(),
|
||||||
|
availableTerminalHeight: 40,
|
||||||
|
terminalWidth: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to a light theme when terminal background is light and no theme is set', () => {
|
||||||
|
const settings = createMockSettings(); // No theme set
|
||||||
|
const { lastFrame } = renderWithProviders(
|
||||||
|
<ThemeDialog {...baseProps} settings={settings} />,
|
||||||
|
{
|
||||||
|
settings,
|
||||||
|
uiState: { terminalBackgroundColor: '#FFFFFF' }, // Light background
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// The snapshot will show which theme is highlighted.
|
||||||
|
// We expect 'DefaultLight' to be the one with the '>' indicator.
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to a dark theme when terminal background is dark and no theme is set', () => {
|
||||||
|
const settings = createMockSettings(); // No theme set
|
||||||
|
const { lastFrame } = renderWithProviders(
|
||||||
|
<ThemeDialog {...baseProps} settings={settings} />,
|
||||||
|
{
|
||||||
|
settings,
|
||||||
|
uiState: { terminalBackgroundColor: '#000000' }, // Dark background
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// We expect 'DefaultDark' to be highlighted.
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use the theme from settings even if terminal background suggests a different theme type', () => {
|
||||||
|
const settings = createMockSettings({ ui: { theme: 'DefaultLight' } }); // Light theme set
|
||||||
|
const { lastFrame } = renderWithProviders(
|
||||||
|
<ThemeDialog {...baseProps} settings={settings} />,
|
||||||
|
{
|
||||||
|
settings,
|
||||||
|
uiState: { terminalBackgroundColor: '#000000' }, // Dark background
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// We expect 'DefaultLight' to be highlighted, respecting the settings.
|
||||||
|
expect(lastFrame()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Hint Visibility', () => {
|
||||||
|
const baseProps = {
|
||||||
|
onSelect: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onHighlight: vi.fn(),
|
||||||
|
availableTerminalHeight: 40,
|
||||||
|
terminalWidth: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should show hint when theme background matches terminal background', () => {
|
||||||
|
const settings = createMockSettings({ ui: { theme: 'Default' } });
|
||||||
|
const { lastFrame } = renderWithProviders(
|
||||||
|
<ThemeDialog {...baseProps} settings={settings} />,
|
||||||
|
{
|
||||||
|
settings,
|
||||||
|
uiState: { terminalBackgroundColor: '#1E1E2E' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).toContain('(Matches terminal)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show hint when theme background does not match terminal background', () => {
|
||||||
|
const settings = createMockSettings({ ui: { theme: 'Default' } });
|
||||||
|
const { lastFrame } = renderWithProviders(
|
||||||
|
<ThemeDialog {...baseProps} settings={settings} />,
|
||||||
|
{
|
||||||
|
settings,
|
||||||
|
uiState: { terminalBackgroundColor: '#FFFFFF' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastFrame()).not.toContain('(Matches terminal)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useCallback, useState } from 'react';
|
|||||||
import { Box, Text } from 'ink';
|
import { Box, Text } from 'ink';
|
||||||
import { theme } from '../semantic-colors.js';
|
import { theme } from '../semantic-colors.js';
|
||||||
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
|
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
|
||||||
|
import { pickDefaultThemeName } from '../themes/theme.js';
|
||||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||||
import { DiffRenderer } from './messages/DiffRenderer.js';
|
import { DiffRenderer } from './messages/DiffRenderer.js';
|
||||||
import { colorizeCode } from '../utils/CodeColorizer.js';
|
import { colorizeCode } from '../utils/CodeColorizer.js';
|
||||||
@@ -22,6 +23,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
|||||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||||
import { ScopeSelector } from './shared/ScopeSelector.js';
|
import { ScopeSelector } from './shared/ScopeSelector.js';
|
||||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||||
|
import { useUIState } from '../contexts/UIStateContext.js';
|
||||||
|
|
||||||
interface ThemeDialogProps {
|
interface ThemeDialogProps {
|
||||||
/** Callback function when a theme is selected */
|
/** Callback function when a theme is selected */
|
||||||
@@ -38,6 +40,42 @@ interface ThemeDialogProps {
|
|||||||
terminalWidth: number;
|
terminalWidth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import {
|
||||||
|
getThemeTypeFromBackgroundColor,
|
||||||
|
resolveColor,
|
||||||
|
} from '../themes/color-utils.js';
|
||||||
|
|
||||||
|
function generateThemeItem(
|
||||||
|
name: string,
|
||||||
|
typeDisplay: string,
|
||||||
|
themeType: string,
|
||||||
|
themeBackground: string | undefined,
|
||||||
|
terminalBackgroundColor: string | undefined,
|
||||||
|
terminalThemeType: 'light' | 'dark' | undefined,
|
||||||
|
) {
|
||||||
|
const isCompatible =
|
||||||
|
themeType === 'custom' ||
|
||||||
|
terminalThemeType === undefined ||
|
||||||
|
themeType === 'ansi' ||
|
||||||
|
themeType === terminalThemeType;
|
||||||
|
|
||||||
|
const isBackgroundMatch =
|
||||||
|
terminalBackgroundColor &&
|
||||||
|
themeBackground &&
|
||||||
|
terminalBackgroundColor.toLowerCase() === themeBackground.toLowerCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: name,
|
||||||
|
value: name,
|
||||||
|
themeNameDisplay: name,
|
||||||
|
themeTypeDisplay: typeDisplay,
|
||||||
|
themeWarning: isCompatible ? '' : ' (Incompatible)',
|
||||||
|
themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '',
|
||||||
|
key: name,
|
||||||
|
isCompatible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function ThemeDialog({
|
export function ThemeDialog({
|
||||||
onSelect,
|
onSelect,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -48,13 +86,27 @@ export function ThemeDialog({
|
|||||||
}: ThemeDialogProps): React.JSX.Element {
|
}: ThemeDialogProps): React.JSX.Element {
|
||||||
const isAlternateBuffer = useAlternateBuffer();
|
const isAlternateBuffer = useAlternateBuffer();
|
||||||
const { refreshStatic } = useUIActions();
|
const { refreshStatic } = useUIActions();
|
||||||
|
const { terminalBackgroundColor } = useUIState();
|
||||||
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
|
||||||
SettingScope.User,
|
SettingScope.User,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the currently highlighted theme name
|
// Track the currently highlighted theme name
|
||||||
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
|
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
|
||||||
settings.merged.ui?.theme || DEFAULT_THEME.name,
|
() => {
|
||||||
|
// If a theme is already set, use it.
|
||||||
|
if (settings.merged.ui?.theme) {
|
||||||
|
return settings.merged.ui.theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, try to pick a theme that matches the terminal background.
|
||||||
|
return pickDefaultThemeName(
|
||||||
|
terminalBackgroundColor,
|
||||||
|
themeManager.getAllThemes(),
|
||||||
|
DEFAULT_THEME.name,
|
||||||
|
'Default Light',
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate theme items filtered by selected scope
|
// Generate theme items filtered by selected scope
|
||||||
@@ -67,23 +119,49 @@ export function ThemeDialog({
|
|||||||
.filter((theme) => theme.type !== 'custom');
|
.filter((theme) => theme.type !== 'custom');
|
||||||
const customThemeNames = Object.keys(customThemes);
|
const customThemeNames = Object.keys(customThemes);
|
||||||
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
|
|
||||||
|
const terminalThemeType = getThemeTypeFromBackgroundColor(
|
||||||
|
terminalBackgroundColor,
|
||||||
|
);
|
||||||
|
|
||||||
// Generate theme items
|
// Generate theme items
|
||||||
const themeItems = [
|
const themeItems = [
|
||||||
...builtInThemes.map((theme) => ({
|
...builtInThemes.map((theme) => {
|
||||||
label: theme.name,
|
const fullTheme = themeManager.getTheme(theme.name);
|
||||||
value: theme.name,
|
const themeBackground = fullTheme
|
||||||
themeNameDisplay: theme.name,
|
? resolveColor(fullTheme.colors.Background)
|
||||||
themeTypeDisplay: capitalize(theme.type),
|
: undefined;
|
||||||
key: theme.name,
|
|
||||||
})),
|
return generateThemeItem(
|
||||||
...customThemeNames.map((name) => ({
|
theme.name,
|
||||||
label: name,
|
capitalize(theme.type),
|
||||||
value: name,
|
theme.type,
|
||||||
themeNameDisplay: name,
|
themeBackground,
|
||||||
themeTypeDisplay: 'Custom',
|
terminalBackgroundColor,
|
||||||
key: name,
|
terminalThemeType,
|
||||||
})),
|
);
|
||||||
];
|
}),
|
||||||
|
...customThemeNames.map((name) => {
|
||||||
|
const themeConfig = customThemes[name];
|
||||||
|
const bg = themeConfig.background?.primary ?? themeConfig.Background;
|
||||||
|
const themeBackground = bg ? resolveColor(bg) : undefined;
|
||||||
|
|
||||||
|
return generateThemeItem(
|
||||||
|
name,
|
||||||
|
'Custom',
|
||||||
|
'custom',
|
||||||
|
themeBackground,
|
||||||
|
terminalBackgroundColor,
|
||||||
|
terminalThemeType,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
].sort((a, b) => {
|
||||||
|
// Show compatible themes first
|
||||||
|
if (a.isCompatible && !b.isCompatible) return -1;
|
||||||
|
if (!a.isCompatible && b.isCompatible) return 1;
|
||||||
|
// Then sort by name
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
|
||||||
// Find the index of the selected theme, but only if it exists in the list
|
// Find the index of the selected theme, but only if it exists in the list
|
||||||
const initialThemeIndex = themeItems.findIndex(
|
const initialThemeIndex = themeItems.findIndex(
|
||||||
@@ -225,6 +303,40 @@ export function ThemeDialog({
|
|||||||
maxItemsToShow={12}
|
maxItemsToShow={12}
|
||||||
showScrollArrows={true}
|
showScrollArrows={true}
|
||||||
showNumbers={mode === 'theme'}
|
showNumbers={mode === 'theme'}
|
||||||
|
renderItem={(item, { titleColor }) => {
|
||||||
|
// We know item has themeWarning because we put it there, but we need to cast or access safely
|
||||||
|
const itemWithExtras = item as typeof item & {
|
||||||
|
themeWarning?: string;
|
||||||
|
themeMatch?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||||
|
return (
|
||||||
|
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||||
|
{item.themeNameDisplay}{' '}
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{item.themeTypeDisplay}
|
||||||
|
</Text>
|
||||||
|
{itemWithExtras.themeMatch && (
|
||||||
|
<Text color={theme.status.success}>
|
||||||
|
{itemWithExtras.themeMatch}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{itemWithExtras.themeWarning && (
|
||||||
|
<Text color={theme.status.warning}>
|
||||||
|
{itemWithExtras.themeWarning}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Regular label display
|
||||||
|
return (
|
||||||
|
<Text color={titleColor} wrap="truncate">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -239,6 +351,7 @@ export function ThemeDialog({
|
|||||||
themeManager.getTheme(
|
themeManager.getTheme(
|
||||||
highlightedThemeName || DEFAULT_THEME.name,
|
highlightedThemeName || DEFAULT_THEME.name,
|
||||||
) || DEFAULT_THEME;
|
) || DEFAULT_THEME;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
borderStyle="single"
|
borderStyle="single"
|
||||||
|
|||||||
@@ -1,5 +1,77 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`Initial Theme Selection > should default to a dark theme when terminal background is dark and no theme is set 1`] = `
|
||||||
|
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ > Select Theme Preview │
|
||||||
|
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ 1. ANSI Dark │ │ │
|
||||||
|
│ 2. Atom One Dark │ 1 # function │ │
|
||||||
|
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
|
||||||
|
│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||||
|
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||||
|
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||||
|
│ 7. Holiday Dark │ 6 return a │ │
|
||||||
|
│ 8. Shades Of Purple Dark │ │ │
|
||||||
|
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||||
|
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||||
|
│ 11. Default Light Light (Incompatible) │ │ │
|
||||||
|
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ │
|
||||||
|
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||||
|
│ │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Initial Theme Selection > should default to a light theme when terminal background is light and no theme is set 1`] = `
|
||||||
|
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ > Select Theme Preview │
|
||||||
|
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ 1. ANSI Light Light │ │ │
|
||||||
|
│ 2. Ayu Light Light │ 1 # function │ │
|
||||||
|
│ ● 3. Default Light Light │ 2 def fibonacci(n): │ │
|
||||||
|
│ 4. GitHub Light Light │ 3 a, b = 0, 1 │ │
|
||||||
|
│ 5. Google Code Light │ 4 for _ in range(n): │ │
|
||||||
|
│ 6. Xcode Light │ 5 a, b = b, a + b │ │
|
||||||
|
│ 7. ANSI Dark (Incompatible) │ 6 return a │ │
|
||||||
|
│ 8. Atom One Dark (Incompatible) │ │ │
|
||||||
|
│ 9. Ayu Dark (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||||
|
│ 10. Default Dark (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||||
|
│ 11. Dracula Dark (Incompatible) │ │ │
|
||||||
|
│ 12. GitHub Dark (Incompatible) └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ │
|
||||||
|
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||||
|
│ │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Initial Theme Selection > should use the theme from settings even if terminal background suggests a different theme type 1`] = `
|
||||||
|
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ │
|
||||||
|
│ > Select Theme Preview │
|
||||||
|
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ ● 1. ANSI Dark │ │ │
|
||||||
|
│ 2. Atom One Dark │ 1 # function │ │
|
||||||
|
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
|
||||||
|
│ 4. Default Dark │ 3 a, b = 0, 1 │ │
|
||||||
|
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
||||||
|
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
||||||
|
│ 7. Holiday Dark │ 6 return a │ │
|
||||||
|
│ 8. Shades Of Purple Dark │ │ │
|
||||||
|
│ 9. ANSI Light Light (Incompatible) │ 1 - print("Hello, " + name) │ │
|
||||||
|
│ 10. Ayu Light Light (Incompatible) │ 1 + print(f"Hello, {name}!") │ │
|
||||||
|
│ 11. Default Light Light (Incompatible) │ │ │
|
||||||
|
│ 12. GitHub Light Light (Incompatible) └────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ ▼ │
|
||||||
|
│ │
|
||||||
|
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||||
|
│ │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = `
|
exports[`ThemeDialog Snapshots > should render correctly in scope selector mode 1`] = `
|
||||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||||
│ │
|
│ │
|
||||||
@@ -19,17 +91,17 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
|||||||
│ > Select Theme Preview │
|
│ > Select Theme Preview │
|
||||||
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
│ ▲ ┌────────────────────────────────────────────────────────────┐ │
|
||||||
│ 1. ANSI Dark │ │ │
|
│ 1. ANSI Dark │ │ │
|
||||||
│ 2. Atom One Dark │ 1 # function │ │
|
│ 2. ANSI Light Light │ 1 # function │ │
|
||||||
│ 3. Ayu Dark │ 2 def fibonacci(n): │ │
|
│ 3. Atom One Dark │ 2 def fibonacci(n): │ │
|
||||||
│ ● 4. Default Dark │ 3 a, b = 0, 1 │ │
|
│ 4. Ayu Dark │ 3 a, b = 0, 1 │ │
|
||||||
│ 5. Dracula Dark │ 4 for _ in range(n): │ │
|
│ 5. Ayu Light Light │ 4 for _ in range(n): │ │
|
||||||
│ 6. GitHub Dark │ 5 a, b = b, a + b │ │
|
│ ● 6. Default Dark │ 5 a, b = b, a + b │ │
|
||||||
│ 7. Holiday Dark │ 6 return a │ │
|
│ 7. Default Light Light │ 6 return a │ │
|
||||||
│ 8. Shades Of Purple Dark │ │ │
|
│ 8. Dracula Dark │ │ │
|
||||||
│ 9. ANSI Light Light │ 1 - print("Hello, " + name) │ │
|
│ 9. GitHub Dark │ 1 - print("Hello, " + name) │ │
|
||||||
│ 10. Ayu Light Light │ 1 + print(f"Hello, {name}!") │ │
|
│ 10. GitHub Light Light │ 1 + print(f"Hello, {name}!") │ │
|
||||||
│ 11. Default Light Light │ │ │
|
│ 11. Google Code Light │ │ │
|
||||||
│ 12. GitHub Light Light └────────────────────────────────────────────────────────────┘ │
|
│ 12. Holiday Dark └────────────────────────────────────────────────────────────┘ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ │
|
│ │
|
||||||
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
│ (Use Enter to select, Tab to configure scope, Esc to close) │
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
import { Text } from 'ink';
|
import { Text } from 'ink';
|
||||||
import { theme } from '../../semantic-colors.js';
|
import { theme } from '../../semantic-colors.js';
|
||||||
import { BaseSelectionList } from './BaseSelectionList.js';
|
import {
|
||||||
|
BaseSelectionList,
|
||||||
|
type RenderItemContext,
|
||||||
|
} from './BaseSelectionList.js';
|
||||||
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +44,11 @@ export interface RadioButtonSelectProps<T> {
|
|||||||
maxItemsToShow?: number;
|
maxItemsToShow?: number;
|
||||||
/** Whether to show numbers next to items. */
|
/** Whether to show numbers next to items. */
|
||||||
showNumbers?: boolean;
|
showNumbers?: boolean;
|
||||||
|
/** Optional custom renderer for items. */
|
||||||
|
renderItem?: (
|
||||||
|
item: RadioSelectItem<T>,
|
||||||
|
context: RenderItemContext,
|
||||||
|
) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +66,7 @@ export function RadioButtonSelect<T>({
|
|||||||
showScrollArrows = false,
|
showScrollArrows = false,
|
||||||
maxItemsToShow = 10,
|
maxItemsToShow = 10,
|
||||||
showNumbers = true,
|
showNumbers = true,
|
||||||
|
renderItem,
|
||||||
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
}: RadioButtonSelectProps<T>): React.JSX.Element {
|
||||||
return (
|
return (
|
||||||
<BaseSelectionList<T, RadioSelectItem<T>>
|
<BaseSelectionList<T, RadioSelectItem<T>>
|
||||||
@@ -69,23 +78,28 @@ export function RadioButtonSelect<T>({
|
|||||||
showNumbers={showNumbers}
|
showNumbers={showNumbers}
|
||||||
showScrollArrows={showScrollArrows}
|
showScrollArrows={showScrollArrows}
|
||||||
maxItemsToShow={maxItemsToShow}
|
maxItemsToShow={maxItemsToShow}
|
||||||
renderItem={(item, { titleColor }) => {
|
renderItem={
|
||||||
// Handle special theme display case for ThemeDialog compatibility
|
renderItem ||
|
||||||
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
((item, { titleColor }) => {
|
||||||
|
// Handle special theme display case for ThemeDialog compatibility
|
||||||
|
if (item.themeNameDisplay && item.themeTypeDisplay) {
|
||||||
|
return (
|
||||||
|
<Text color={titleColor} wrap="truncate" key={item.key}>
|
||||||
|
{item.themeNameDisplay}{' '}
|
||||||
|
<Text color={theme.text.secondary}>
|
||||||
|
{item.themeTypeDisplay}
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Regular label display
|
||||||
return (
|
return (
|
||||||
<Text color={titleColor} wrap="truncate" key={item.key}>
|
<Text color={titleColor} wrap="truncate">
|
||||||
{item.themeNameDisplay}{' '}
|
{item.label}
|
||||||
<Text color={theme.text.secondary}>{item.themeTypeDisplay}</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
})
|
||||||
// Regular label display
|
}
|
||||||
return (
|
|
||||||
<Text color={titleColor} wrap="truncate">
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { parsePastedPaths } from '../../utils/clipboardUtils.js';
|
|||||||
import type { Key } from '../../contexts/KeypressContext.js';
|
import type { Key } from '../../contexts/KeypressContext.js';
|
||||||
import type { VimAction } from './vim-buffer-actions.js';
|
import type { VimAction } from './vim-buffer-actions.js';
|
||||||
import { handleVimAction } from './vim-buffer-actions.js';
|
import { handleVimAction } from './vim-buffer-actions.js';
|
||||||
import { enableSupportedProtocol } from '../../utils/kittyProtocolDetector.js';
|
|
||||||
|
|
||||||
export type Direction =
|
export type Direction =
|
||||||
| 'left'
|
| 'left'
|
||||||
@@ -1914,7 +1913,6 @@ export function useTextBuffer({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[useTextBuffer] external editor error', err);
|
console.error('[useTextBuffer] external editor error', err);
|
||||||
} finally {
|
} finally {
|
||||||
enableSupportedProtocol();
|
|
||||||
coreEvents.emit(CoreEvent.ExternalEditorClosed);
|
coreEvents.emit(CoreEvent.ExternalEditorClosed);
|
||||||
if (wasRaw) setRawMode?.(true);
|
if (wasRaw) setRawMode?.(true);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export interface ProQuotaDialogRequest {
|
|||||||
|
|
||||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||||
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
|
||||||
|
import type { TerminalBackgroundColor } from '../utils/terminalCapabilityManager.js';
|
||||||
|
|
||||||
export interface UIState {
|
export interface UIState {
|
||||||
history: HistoryItem[];
|
history: HistoryItem[];
|
||||||
@@ -136,6 +137,7 @@ export interface UIState {
|
|||||||
};
|
};
|
||||||
bannerVisible: boolean;
|
bannerVisible: boolean;
|
||||||
customDialog: React.ReactNode | null;
|
customDialog: React.ReactNode | null;
|
||||||
|
terminalBackgroundColor: TerminalBackgroundColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UIStateContext = createContext<UIState | null>(null);
|
export const UIStateContext = createContext<UIState | null>(null);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { isKittyProtocolEnabled } from '../utils/kittyProtocolDetector.js';
|
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
|
||||||
|
|
||||||
export interface KittyProtocolStatus {
|
export interface KittyProtocolStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -18,7 +18,7 @@ export interface KittyProtocolStatus {
|
|||||||
*/
|
*/
|
||||||
export function useKittyKeyboardProtocol(): KittyProtocolStatus {
|
export function useKittyKeyboardProtocol(): KittyProtocolStatus {
|
||||||
const [status] = useState<KittyProtocolStatus>({
|
const [status] = useState<KittyProtocolStatus>({
|
||||||
enabled: isKittyProtocolEnabled(),
|
enabled: terminalCapabilityManager.isKittyProtocolEnabled(),
|
||||||
checking: false,
|
checking: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
interpolateColor,
|
interpolateColor,
|
||||||
CSS_NAME_TO_HEX_MAP,
|
CSS_NAME_TO_HEX_MAP,
|
||||||
INK_SUPPORTED_NAMES,
|
INK_SUPPORTED_NAMES,
|
||||||
|
getThemeTypeFromBackgroundColor,
|
||||||
} from './color-utils.js';
|
} from './color-utils.js';
|
||||||
|
|
||||||
describe('Color Utils', () => {
|
describe('Color Utils', () => {
|
||||||
@@ -255,4 +256,27 @@ describe('Color Utils', () => {
|
|||||||
expect(interpolateColor('#ffffff', '', 1)).toBe('');
|
expect(interpolateColor('#ffffff', '', 1)).toBe('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getThemeTypeFromBackgroundColor', () => {
|
||||||
|
it('should return light for light backgrounds', () => {
|
||||||
|
expect(getThemeTypeFromBackgroundColor('#ffffff')).toBe('light');
|
||||||
|
expect(getThemeTypeFromBackgroundColor('#f0f0f0')).toBe('light');
|
||||||
|
expect(getThemeTypeFromBackgroundColor('#cccccc')).toBe('light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return dark for dark backgrounds', () => {
|
||||||
|
expect(getThemeTypeFromBackgroundColor('#000000')).toBe('dark');
|
||||||
|
expect(getThemeTypeFromBackgroundColor('#1a1a1a')).toBe('dark');
|
||||||
|
expect(getThemeTypeFromBackgroundColor('#333333')).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for undefined background', () => {
|
||||||
|
expect(getThemeTypeFromBackgroundColor(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle colors without # prefix', () => {
|
||||||
|
expect(getThemeTypeFromBackgroundColor('ffffff')).toBe('light');
|
||||||
|
expect(getThemeTypeFromBackgroundColor('000000')).toBe('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -251,3 +251,22 @@ export function interpolateColor(
|
|||||||
const color = gradient.rgbAt(factor);
|
const color = gradient.rgbAt(factor);
|
||||||
return color.toHexString();
|
return color.toHexString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getThemeTypeFromBackgroundColor(
|
||||||
|
backgroundColor: string | undefined,
|
||||||
|
): 'light' | 'dark' | undefined {
|
||||||
|
if (!backgroundColor) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse hex color
|
||||||
|
const hex = backgroundColor.replace(/^#/, '');
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Calculate luminance
|
||||||
|
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
|
||||||
|
return luminance > 128 ? 'light' : 'dark';
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { interpolateColor } from './color-utils.js';
|
|||||||
const shadesOfPurpleColors: ColorsTheme = {
|
const shadesOfPurpleColors: ColorsTheme = {
|
||||||
type: 'dark',
|
type: 'dark',
|
||||||
// Required colors for ColorsTheme interface
|
// Required colors for ColorsTheme interface
|
||||||
Background: '#2d2b57', // Main background
|
Background: '#1e1e3f', // Main background in the VSCode terminal.
|
||||||
Foreground: '#e3dfff', // Default text color (hljs, hljs-subst)
|
Foreground: '#e3dfff', // Default text color (hljs, hljs-subst)
|
||||||
LightBlue: '#847ace', // Light blue/purple accent
|
LightBlue: '#847ace', // Light blue/purple accent
|
||||||
AccentBlue: '#a599e9', // Borders, secondary blue
|
AccentBlue: '#a599e9', // Borders, secondary blue
|
||||||
|
|||||||
@@ -228,6 +228,14 @@ class ThemeManager {
|
|||||||
return this.findThemeByName(themeName);
|
return this.findThemeByName(themeName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all available themes.
|
||||||
|
* @returns A list of all available themes.
|
||||||
|
*/
|
||||||
|
getAllThemes(): Theme[] {
|
||||||
|
return [...this.availableThemes, ...Array.from(this.customThemes.values())];
|
||||||
|
}
|
||||||
|
|
||||||
private isPath(themeName: string): boolean {
|
private isPath(themeName: string): boolean {
|
||||||
return (
|
return (
|
||||||
themeName.endsWith('.json') ||
|
themeName.endsWith('.json') ||
|
||||||
|
|||||||
@@ -168,3 +168,42 @@ describe('themeManager.loadCustomThemes', () => {
|
|||||||
expect(result.name).toBe(legacyTheme.name);
|
expect(result.name).toBe(legacyTheme.name);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('pickDefaultThemeName', () => {
|
||||||
|
const { pickDefaultThemeName } = themeModule;
|
||||||
|
const mockThemes = [
|
||||||
|
{ name: 'Dark Theme', type: 'dark', colors: { Background: '#000000' } },
|
||||||
|
{ name: 'Light Theme', type: 'light', colors: { Background: '#ffffff' } },
|
||||||
|
{ name: 'Blue Theme', type: 'dark', colors: { Background: '#0000ff' } },
|
||||||
|
] as unknown as themeModule.Theme[];
|
||||||
|
|
||||||
|
it('should return exact match if found', () => {
|
||||||
|
expect(
|
||||||
|
pickDefaultThemeName('#0000ff', mockThemes, 'Dark Theme', 'Light Theme'),
|
||||||
|
).toBe('Blue Theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return exact match (case insensitive)', () => {
|
||||||
|
expect(
|
||||||
|
pickDefaultThemeName('#FFFFFF', mockThemes, 'Dark Theme', 'Light Theme'),
|
||||||
|
).toBe('Light Theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default light theme for light background if no match', () => {
|
||||||
|
expect(
|
||||||
|
pickDefaultThemeName('#eeeeee', mockThemes, 'Dark Theme', 'Light Theme'),
|
||||||
|
).toBe('Light Theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default dark theme for dark background if no match', () => {
|
||||||
|
expect(
|
||||||
|
pickDefaultThemeName('#111111', mockThemes, 'Dark Theme', 'Light Theme'),
|
||||||
|
).toBe('Dark Theme');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default dark theme if background is undefined', () => {
|
||||||
|
expect(
|
||||||
|
pickDefaultThemeName(undefined, mockThemes, 'Dark Theme', 'Light Theme'),
|
||||||
|
).toBe('Dark Theme');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
import type { CSSProperties } from 'react';
|
import type { CSSProperties } from 'react';
|
||||||
import type { SemanticColors } from './semantic-tokens.js';
|
import type { SemanticColors } from './semantic-tokens.js';
|
||||||
import { resolveColor, interpolateColor } from './color-utils.js';
|
import {
|
||||||
|
resolveColor,
|
||||||
|
interpolateColor,
|
||||||
|
getThemeTypeFromBackgroundColor,
|
||||||
|
} from './color-utils.js';
|
||||||
|
|
||||||
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
|
export type ThemeType = 'light' | 'dark' | 'ansi' | 'custom';
|
||||||
|
|
||||||
@@ -499,3 +503,40 @@ function isValidThemeName(name: string): boolean {
|
|||||||
// Theme name should be non-empty and not contain invalid characters
|
// Theme name should be non-empty and not contain invalid characters
|
||||||
return name.trim().length > 0 && name.trim().length <= 50;
|
return name.trim().length > 0 && name.trim().length <= 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks a default theme name based on terminal background color.
|
||||||
|
* It first tries to find a theme with an exact background color match.
|
||||||
|
* If no match is found, it falls back to a light or dark theme based on the
|
||||||
|
* luminance of the background color.
|
||||||
|
* @param terminalBackground The hex color string of the terminal background.
|
||||||
|
* @param availableThemes A list of available themes to search through.
|
||||||
|
* @param defaultDarkThemeName The name of the fallback dark theme.
|
||||||
|
* @param defaultLightThemeName The name of the fallback light theme.
|
||||||
|
* @returns The name of the chosen theme.
|
||||||
|
*/
|
||||||
|
export function pickDefaultThemeName(
|
||||||
|
terminalBackground: string | undefined,
|
||||||
|
availableThemes: readonly Theme[],
|
||||||
|
defaultDarkThemeName: string,
|
||||||
|
defaultLightThemeName: string,
|
||||||
|
): string {
|
||||||
|
if (terminalBackground) {
|
||||||
|
const lowerTerminalBackground = terminalBackground.toLowerCase();
|
||||||
|
for (const theme of availableThemes) {
|
||||||
|
if (!theme.colors.Background) continue;
|
||||||
|
// resolveColor can return undefined
|
||||||
|
const themeBg = resolveColor(theme.colors.Background)?.toLowerCase();
|
||||||
|
if (themeBg === lowerTerminalBackground) {
|
||||||
|
return theme.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeType = getThemeTypeFromBackgroundColor(terminalBackground);
|
||||||
|
if (themeType === 'light') {
|
||||||
|
return defaultLightThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultDarkThemeName;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,145 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
||||||
|
|
||||||
// Mock dependencies
|
|
||||||
const mocks = vi.hoisted(() => ({
|
|
||||||
writeSync: vi.fn(),
|
|
||||||
enableKittyKeyboardProtocol: vi.fn(),
|
|
||||||
disableKittyKeyboardProtocol: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('node:fs', () => ({
|
|
||||||
writeSync: mocks.writeSync,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@google/gemini-cli-core', () => ({
|
|
||||||
enableKittyKeyboardProtocol: mocks.enableKittyKeyboardProtocol,
|
|
||||||
disableKittyKeyboardProtocol: mocks.disableKittyKeyboardProtocol,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('kittyProtocolDetector', () => {
|
|
||||||
let originalStdin: NodeJS.ReadStream & { fd?: number };
|
|
||||||
let originalStdout: NodeJS.WriteStream & { fd?: number };
|
|
||||||
let stdinListeners: Record<string, (data: Buffer) => void> = {};
|
|
||||||
|
|
||||||
// Module functions
|
|
||||||
let detectAndEnableKittyProtocol: typeof import('./kittyProtocolDetector.js').detectAndEnableKittyProtocol;
|
|
||||||
let isKittyProtocolEnabled: typeof import('./kittyProtocolDetector.js').isKittyProtocolEnabled;
|
|
||||||
let enableSupportedProtocol: typeof import('./kittyProtocolDetector.js').enableSupportedProtocol;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
vi.resetAllMocks();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const mod = await import('./kittyProtocolDetector.js');
|
|
||||||
detectAndEnableKittyProtocol = mod.detectAndEnableKittyProtocol;
|
|
||||||
isKittyProtocolEnabled = mod.isKittyProtocolEnabled;
|
|
||||||
enableSupportedProtocol = mod.enableSupportedProtocol;
|
|
||||||
|
|
||||||
// Mock process.stdin and stdout
|
|
||||||
originalStdin = process.stdin;
|
|
||||||
originalStdout = process.stdout;
|
|
||||||
|
|
||||||
stdinListeners = {};
|
|
||||||
|
|
||||||
Object.defineProperty(process, 'stdin', {
|
|
||||||
value: {
|
|
||||||
isTTY: true,
|
|
||||||
isRaw: false,
|
|
||||||
setRawMode: vi.fn(),
|
|
||||||
on: vi.fn((event, handler) => {
|
|
||||||
stdinListeners[event] = handler;
|
|
||||||
}),
|
|
||||||
removeListener: vi.fn(),
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.defineProperty(process, 'stdout', {
|
|
||||||
value: {
|
|
||||||
isTTY: true,
|
|
||||||
fd: 1,
|
|
||||||
},
|
|
||||||
configurable: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
Object.defineProperty(process, 'stdin', { value: originalStdin });
|
|
||||||
Object.defineProperty(process, 'stdout', { value: originalStdout });
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should resolve immediately if not TTY', async () => {
|
|
||||||
Object.defineProperty(process.stdin, 'isTTY', { value: false });
|
|
||||||
await detectAndEnableKittyProtocol();
|
|
||||||
expect(mocks.writeSync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should enable protocol if response indicates support', async () => {
|
|
||||||
const promise = detectAndEnableKittyProtocol();
|
|
||||||
|
|
||||||
// Simulate response
|
|
||||||
expect(stdinListeners['data']).toBeDefined();
|
|
||||||
|
|
||||||
// Send progressive enhancement response
|
|
||||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
|
||||||
|
|
||||||
// Send device attributes response
|
|
||||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
|
||||||
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
|
||||||
expect(isKittyProtocolEnabled()).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not enable protocol if timeout occurs', async () => {
|
|
||||||
const promise = detectAndEnableKittyProtocol();
|
|
||||||
|
|
||||||
// Fast forward time past timeout
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(mocks.enableKittyKeyboardProtocol).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should wait longer if progressive enhancement received but not attributes', async () => {
|
|
||||||
const promise = detectAndEnableKittyProtocol();
|
|
||||||
|
|
||||||
// Send progressive enhancement response
|
|
||||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
|
||||||
|
|
||||||
// Should not resolve yet
|
|
||||||
vi.advanceTimersByTime(300); // Original timeout passed
|
|
||||||
|
|
||||||
// Send device attributes response late
|
|
||||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
|
||||||
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle re-enabling protocol', async () => {
|
|
||||||
// First, simulate successful detection to set kittySupported = true
|
|
||||||
const promise = detectAndEnableKittyProtocol();
|
|
||||||
stdinListeners['data'](Buffer.from('\x1b[?u'));
|
|
||||||
stdinListeners['data'](Buffer.from('\x1b[?c'));
|
|
||||||
await promise;
|
|
||||||
|
|
||||||
// Reset mocks to clear previous calls
|
|
||||||
mocks.enableKittyKeyboardProtocol.mockClear();
|
|
||||||
|
|
||||||
// Now test re-enabling
|
|
||||||
enableSupportedProtocol();
|
|
||||||
expect(mocks.enableKittyKeyboardProtocol).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
/**
|
|
||||||
* @license
|
|
||||||
* Copyright 2025 Google LLC
|
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
|
|
||||||
let detectionComplete = false;
|
|
||||||
|
|
||||||
let kittySupported = false;
|
|
||||||
|
|
||||||
let kittyEnabled = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects Kitty keyboard protocol support.
|
|
||||||
* Definitive document about this protocol lives at https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
||||||
* This function should be called once at app startup.
|
|
||||||
*/
|
|
||||||
export async function detectAndEnableKittyProtocol(): Promise<void> {
|
|
||||||
if (detectionComplete) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
||||||
detectionComplete = true;
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalRawMode = process.stdin.isRaw;
|
|
||||||
if (!originalRawMode) {
|
|
||||||
process.stdin.setRawMode(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
let responseBuffer = '';
|
|
||||||
let progressiveEnhancementReceived = false;
|
|
||||||
let timeoutId: NodeJS.Timeout | undefined;
|
|
||||||
|
|
||||||
const finish = () => {
|
|
||||||
if (timeoutId !== undefined) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = undefined;
|
|
||||||
}
|
|
||||||
process.stdin.removeListener('data', handleData);
|
|
||||||
if (!originalRawMode) {
|
|
||||||
process.stdin.setRawMode(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (kittySupported) {
|
|
||||||
enableSupportedProtocol();
|
|
||||||
process.on('exit', disableAllProtocols);
|
|
||||||
process.on('SIGTERM', disableAllProtocols);
|
|
||||||
}
|
|
||||||
|
|
||||||
detectionComplete = true;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleData = (data: Buffer) => {
|
|
||||||
if (timeoutId === undefined) {
|
|
||||||
// Race condition. We have already timed out.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
responseBuffer += data.toString();
|
|
||||||
|
|
||||||
// Check for progressive enhancement response (CSI ? <flags> u)
|
|
||||||
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('u')) {
|
|
||||||
progressiveEnhancementReceived = true;
|
|
||||||
// Give more time to get the full set of kitty responses if we have an
|
|
||||||
// indication the terminal probably supports kitty and we just need to
|
|
||||||
// wait a bit longer for a response.
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
timeoutId = setTimeout(finish, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for device attributes response (CSI ? <attrs> c)
|
|
||||||
if (responseBuffer.includes('\x1b[?') && responseBuffer.includes('c')) {
|
|
||||||
if (progressiveEnhancementReceived) {
|
|
||||||
kittySupported = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
process.stdin.on('data', handleData);
|
|
||||||
|
|
||||||
// Query progressive enhancement and device attributes
|
|
||||||
fs.writeSync(process.stdout.fd, '\x1b[?u\x1b[c');
|
|
||||||
|
|
||||||
// Timeout after 200ms
|
|
||||||
// When a iterm2 terminal does not have focus this can take over 90s on a
|
|
||||||
// fast macbook so we need a somewhat longer threshold than would be ideal.
|
|
||||||
timeoutId = setTimeout(finish, 200);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
import {
|
|
||||||
enableKittyKeyboardProtocol,
|
|
||||||
disableKittyKeyboardProtocol,
|
|
||||||
} from '@google/gemini-cli-core';
|
|
||||||
|
|
||||||
export function isKittyProtocolEnabled(): boolean {
|
|
||||||
return kittyEnabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function disableAllProtocols() {
|
|
||||||
try {
|
|
||||||
if (kittyEnabled) {
|
|
||||||
disableKittyKeyboardProtocol();
|
|
||||||
kittyEnabled = false;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is exported so we can reenable this after exiting an editor which might
|
|
||||||
* change the mode.
|
|
||||||
*/
|
|
||||||
export function enableSupportedProtocol(): void {
|
|
||||||
try {
|
|
||||||
if (kittySupported) {
|
|
||||||
enableKittyKeyboardProtocol();
|
|
||||||
kittyEnabled = true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { TerminalCapabilityManager } from './terminalCapabilityManager.js';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
|
// Mock fs
|
||||||
|
vi.mock('node:fs', () => ({
|
||||||
|
writeSync: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock core
|
||||||
|
vi.mock('@google/gemini-cli-core', () => ({
|
||||||
|
debugLogger: {
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
},
|
||||||
|
enableKittyKeyboardProtocol: vi.fn(),
|
||||||
|
disableKittyKeyboardProtocol: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('TerminalCapabilityManager', () => {
|
||||||
|
let stdin: EventEmitter & {
|
||||||
|
isTTY?: boolean;
|
||||||
|
isRaw?: boolean;
|
||||||
|
setRawMode?: (mode: boolean) => void;
|
||||||
|
removeListener?: (
|
||||||
|
event: string,
|
||||||
|
listener: (...args: unknown[]) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
let stdout: { isTTY?: boolean; fd?: number };
|
||||||
|
// Save original process properties
|
||||||
|
const originalStdin = process.stdin;
|
||||||
|
const originalStdout = process.stdout;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
|
||||||
|
// Reset singleton
|
||||||
|
TerminalCapabilityManager.resetInstanceForTesting();
|
||||||
|
|
||||||
|
// Setup process mocks
|
||||||
|
stdin = new EventEmitter();
|
||||||
|
stdin.isTTY = true;
|
||||||
|
stdin.isRaw = false;
|
||||||
|
stdin.setRawMode = vi.fn();
|
||||||
|
stdin.removeListener = vi.fn();
|
||||||
|
|
||||||
|
stdout = { isTTY: true, fd: 1 };
|
||||||
|
|
||||||
|
// Use defineProperty to mock process.stdin/stdout
|
||||||
|
Object.defineProperty(process, 'stdin', {
|
||||||
|
value: stdin,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(process, 'stdout', {
|
||||||
|
value: stdout,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
// Restore original process properties
|
||||||
|
Object.defineProperty(process, 'stdin', {
|
||||||
|
value: originalStdin,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(process, 'stdout', {
|
||||||
|
value: originalStdout,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Kitty support when u response is received', async () => {
|
||||||
|
const manager = TerminalCapabilityManager.getInstance();
|
||||||
|
const promise = manager.detectCapabilities();
|
||||||
|
|
||||||
|
// Simulate Kitty response: \x1b[?1u
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?1u'));
|
||||||
|
// Complete detection with DA1
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Background Color', async () => {
|
||||||
|
const manager = TerminalCapabilityManager.getInstance();
|
||||||
|
const promise = manager.detectCapabilities();
|
||||||
|
|
||||||
|
// Simulate OSC 11 response
|
||||||
|
// \x1b]11;rgb:0000/ff00/0000\x1b\
|
||||||
|
// RGB: 0, 255, 0 -> #00ff00
|
||||||
|
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/ffff/0000\x1b\\'));
|
||||||
|
// Complete detection with DA1
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
expect(manager.getTerminalBackgroundColor()).toBe('#00ff00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect Terminal Name', async () => {
|
||||||
|
const manager = TerminalCapabilityManager.getInstance();
|
||||||
|
const promise = manager.detectCapabilities();
|
||||||
|
|
||||||
|
// Simulate Terminal Name response
|
||||||
|
stdin.emit('data', Buffer.from('\x1bP>|WezTerm 20240203\x1b\\'));
|
||||||
|
// Complete detection with DA1
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
expect(manager.getTerminalName()).toBe('WezTerm 20240203');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete early if sentinel (DA1) is found', async () => {
|
||||||
|
const manager = TerminalCapabilityManager.getInstance();
|
||||||
|
const promise = manager.detectCapabilities();
|
||||||
|
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?1u'));
|
||||||
|
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/0000/0000\x1b\\'));
|
||||||
|
// Sentinel
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||||
|
|
||||||
|
// Should resolve without waiting for timeout
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||||
|
expect(manager.getTerminalBackgroundColor()).toBe('#000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should timeout if no DA1 (c) is received', async () => {
|
||||||
|
const manager = TerminalCapabilityManager.getInstance();
|
||||||
|
const promise = manager.detectCapabilities();
|
||||||
|
|
||||||
|
// Simulate only Kitty response
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?1u'));
|
||||||
|
|
||||||
|
// Advance to timeout
|
||||||
|
vi.advanceTimersByTime(1000);
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect Kitty if only DA1 (c) is received', async () => {
|
||||||
|
const manager = TerminalCapabilityManager.getInstance();
|
||||||
|
const promise = manager.detectCapabilities();
|
||||||
|
|
||||||
|
// Simulate DA1 response only: \x1b[?62;c
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
expect(manager.isKittyProtocolEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle split chunks', async () => {
|
||||||
|
const manager = TerminalCapabilityManager.getInstance();
|
||||||
|
const promise = manager.detectCapabilities();
|
||||||
|
|
||||||
|
// Split response: \x1b[? 1u
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?'));
|
||||||
|
stdin.emit('data', Buffer.from('1u'));
|
||||||
|
// Complete with DA1
|
||||||
|
stdin.emit('data', Buffer.from('\x1b[?62c'));
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
expect(manager.isKittyProtocolEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import {
|
||||||
|
debugLogger,
|
||||||
|
enableKittyKeyboardProtocol,
|
||||||
|
disableKittyKeyboardProtocol,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
export type TerminalBackgroundColor = string | undefined;
|
||||||
|
|
||||||
|
export class TerminalCapabilityManager {
|
||||||
|
private static instance: TerminalCapabilityManager | undefined;
|
||||||
|
|
||||||
|
private static readonly KITTY_QUERY = '\x1b[?u';
|
||||||
|
private static readonly OSC_11_QUERY = '\x1b]11;?\x1b\\';
|
||||||
|
private static readonly TERMINAL_NAME_QUERY = '\x1b[>q';
|
||||||
|
private static readonly DEVICE_ATTRIBUTES_QUERY = '\x1b[c';
|
||||||
|
|
||||||
|
// Kitty keyboard flags: CSI ? flags u
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
private static readonly KITTY_REGEX = /\x1b\[\?(\d+)u/;
|
||||||
|
// Terminal Name/Version response: DCS > | text ST (or BEL)
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
private static readonly TERMINAL_NAME_REGEX = /\x1bP>\|(.+?)(\x1b\\|\x07)/;
|
||||||
|
// Primary Device Attributes: CSI ? ID ; ... c
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
private static readonly DEVICE_ATTRIBUTES_REGEX = /\x1b\[\?(\d+)(;\d+)*c/;
|
||||||
|
// OSC 11 response: OSC 11 ; rgb:rrrr/gggg/bbbb ST (or BEL)
|
||||||
|
private static readonly OSC_11_REGEX =
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
/\x1b\]11;rgb:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(\x1b\\|\x07)?/;
|
||||||
|
|
||||||
|
private terminalBackgroundColor: TerminalBackgroundColor;
|
||||||
|
private kittySupported = false;
|
||||||
|
private kittyEnabled = false;
|
||||||
|
private detectionComplete = false;
|
||||||
|
private terminalName: string | undefined;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): TerminalCapabilityManager {
|
||||||
|
if (!this.instance) {
|
||||||
|
this.instance = new TerminalCapabilityManager();
|
||||||
|
}
|
||||||
|
return this.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static resetInstanceForTesting(): void {
|
||||||
|
this.instance = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects terminal capabilities (Kitty protocol support, terminal name,
|
||||||
|
* background color).
|
||||||
|
* This should be called once at app startup.
|
||||||
|
*/
|
||||||
|
async detectCapabilities(): Promise<void> {
|
||||||
|
if (this.detectionComplete) return;
|
||||||
|
|
||||||
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||||
|
this.detectionComplete = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const originalRawMode = process.stdin.isRaw;
|
||||||
|
if (!originalRawMode) {
|
||||||
|
process.stdin.setRawMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
let kittyKeyboardReceived = false;
|
||||||
|
let terminalNameReceived = false;
|
||||||
|
let deviceAttributesReceived = false;
|
||||||
|
let bgReceived = false;
|
||||||
|
// eslint-disable-next-line prefer-const
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
process.stdin.removeListener('data', onData);
|
||||||
|
if (!originalRawMode) {
|
||||||
|
process.stdin.setRawMode(false);
|
||||||
|
}
|
||||||
|
this.detectionComplete = true;
|
||||||
|
|
||||||
|
// Auto-enable kitty if supported
|
||||||
|
if (this.kittySupported) {
|
||||||
|
this.enableKittyProtocol();
|
||||||
|
process.on('exit', () => this.disableKittyProtocol());
|
||||||
|
process.on('SIGTERM', () => this.disableKittyProtocol());
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimeout = () => {
|
||||||
|
cleanup();
|
||||||
|
};
|
||||||
|
|
||||||
|
// A somewhat long timeout is acceptable as all terminals should respond
|
||||||
|
// to the device attributes query used as a sentinel.
|
||||||
|
timeoutId = setTimeout(onTimeout, 1000);
|
||||||
|
|
||||||
|
const onData = (data: Buffer) => {
|
||||||
|
buffer += data.toString();
|
||||||
|
|
||||||
|
// Check OSC 11
|
||||||
|
if (!bgReceived) {
|
||||||
|
const match = buffer.match(TerminalCapabilityManager.OSC_11_REGEX);
|
||||||
|
if (match) {
|
||||||
|
bgReceived = true;
|
||||||
|
this.terminalBackgroundColor = this.parseColor(
|
||||||
|
match[1],
|
||||||
|
match[2],
|
||||||
|
match[3],
|
||||||
|
);
|
||||||
|
debugLogger.log(
|
||||||
|
`Detected terminal background color: ${this.terminalBackgroundColor}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!kittyKeyboardReceived &&
|
||||||
|
TerminalCapabilityManager.KITTY_REGEX.test(buffer)
|
||||||
|
) {
|
||||||
|
kittyKeyboardReceived = true;
|
||||||
|
this.kittySupported = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Terminal Name/Version response.
|
||||||
|
if (!terminalNameReceived) {
|
||||||
|
const match = buffer.match(
|
||||||
|
TerminalCapabilityManager.TERMINAL_NAME_REGEX,
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
terminalNameReceived = true;
|
||||||
|
this.terminalName = match[1];
|
||||||
|
|
||||||
|
debugLogger.log(`Detected terminal name: ${this.terminalName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We use the Primary Device Attributes response as a sentinel to know
|
||||||
|
// that the terminal has processed all our queries. Since we send it
|
||||||
|
// last, receiving it means we can stop waiting.
|
||||||
|
if (!deviceAttributesReceived) {
|
||||||
|
const match = buffer.match(
|
||||||
|
TerminalCapabilityManager.DEVICE_ATTRIBUTES_REGEX,
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
deviceAttributesReceived = true;
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
process.stdin.on('data', onData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeSync(
|
||||||
|
process.stdout.fd,
|
||||||
|
TerminalCapabilityManager.KITTY_QUERY +
|
||||||
|
TerminalCapabilityManager.OSC_11_QUERY +
|
||||||
|
TerminalCapabilityManager.TERMINAL_NAME_QUERY +
|
||||||
|
TerminalCapabilityManager.DEVICE_ATTRIBUTES_QUERY,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
debugLogger.warn('Failed to write terminal capability queries:', e);
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerminalBackgroundColor(): TerminalBackgroundColor {
|
||||||
|
return this.terminalBackgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerminalName(): string | undefined {
|
||||||
|
return this.terminalName;
|
||||||
|
}
|
||||||
|
|
||||||
|
isKittyProtocolEnabled(): boolean {
|
||||||
|
return this.kittyEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
enableKittyProtocol(): void {
|
||||||
|
try {
|
||||||
|
if (this.kittySupported) {
|
||||||
|
enableKittyKeyboardProtocol();
|
||||||
|
this.kittyEnabled = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugLogger.warn('Failed to enable Kitty protocol:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disableKittyProtocol(): void {
|
||||||
|
try {
|
||||||
|
if (this.kittyEnabled) {
|
||||||
|
disableKittyKeyboardProtocol();
|
||||||
|
this.kittyEnabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugLogger.warn('Failed to disable Kitty protocol:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseColor(rHex: string, gHex: string, bHex: string): string {
|
||||||
|
const parseComponent = (hex: string) => {
|
||||||
|
const val = parseInt(hex, 16);
|
||||||
|
if (hex.length === 1) return (val / 15) * 255;
|
||||||
|
if (hex.length === 2) return val;
|
||||||
|
if (hex.length === 3) return (val / 4095) * 255;
|
||||||
|
if (hex.length === 4) return (val / 65535) * 255;
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = parseComponent(rHex);
|
||||||
|
const g = parseComponent(gHex);
|
||||||
|
const b = parseComponent(bHex);
|
||||||
|
|
||||||
|
const toHex = (c: number) => Math.round(c).toString(16).padStart(2, '0');
|
||||||
|
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const terminalCapabilityManager =
|
||||||
|
TerminalCapabilityManager.getInstance();
|
||||||
@@ -42,8 +42,10 @@ vi.mock('node:os', () => ({
|
|||||||
platform: mocks.platform,
|
platform: mocks.platform,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./kittyProtocolDetector.js', () => ({
|
vi.mock('./terminalCapabilityManager.js', () => ({
|
||||||
isKittyProtocolEnabled: vi.fn().mockReturnValue(false),
|
terminalCapabilityManager: {
|
||||||
|
isKittyProtocolEnabled: vi.fn().mockReturnValue(false),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('terminalSetup', () => {
|
describe('terminalSetup', () => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import * as os from 'node:os';
|
|||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import { exec } from 'node:child_process';
|
import { exec } from 'node:child_process';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { isKittyProtocolEnabled } from './kittyProtocolDetector.js';
|
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
|
||||||
|
|
||||||
import { debugLogger } from '@google/gemini-cli-core';
|
import { debugLogger } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
@@ -323,7 +323,7 @@ async function configureWindsurf(): Promise<TerminalSetupResult> {
|
|||||||
*/
|
*/
|
||||||
export async function terminalSetup(): Promise<TerminalSetupResult> {
|
export async function terminalSetup(): Promise<TerminalSetupResult> {
|
||||||
// Check if terminal already has optimal keyboard support
|
// Check if terminal already has optimal keyboard support
|
||||||
if (isKittyProtocolEnabled()) {
|
if (terminalCapabilityManager.isKittyProtocolEnabled()) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type TerminalBackgroundColor,
|
||||||
|
terminalCapabilityManager,
|
||||||
|
} from '../ui/utils/terminalCapabilityManager.js';
|
||||||
|
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
|
||||||
|
import { pickDefaultThemeName } from '../ui/themes/theme.js';
|
||||||
|
import { getThemeTypeFromBackgroundColor } from '../ui/themes/color-utils.js';
|
||||||
|
import type { LoadedSettings } from '../config/settings.js';
|
||||||
|
import { type Config, coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects terminal capabilities, loads themes, and sets the active theme.
|
||||||
|
* @param config The application config.
|
||||||
|
* @param settings The loaded settings.
|
||||||
|
* @returns The detected terminal background color.
|
||||||
|
*/
|
||||||
|
export async function setupTerminalAndTheme(
|
||||||
|
config: Config,
|
||||||
|
settings: LoadedSettings,
|
||||||
|
): Promise<TerminalBackgroundColor> {
|
||||||
|
let terminalBackground: TerminalBackgroundColor = undefined;
|
||||||
|
if (config.isInteractive() && process.stdin.isTTY) {
|
||||||
|
// Detect terminal capabilities (Kitty protocol, background color) in parallel.
|
||||||
|
await terminalCapabilityManager.detectCapabilities();
|
||||||
|
terminalBackground = terminalCapabilityManager.getTerminalBackgroundColor();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load custom themes from settings
|
||||||
|
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);
|
||||||
|
|
||||||
|
if (settings.merged.ui?.theme) {
|
||||||
|
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
|
||||||
|
// If the theme is not found during initial load, log a warning and continue.
|
||||||
|
// The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.
|
||||||
|
debugLogger.warn(
|
||||||
|
`Warning: Theme "${settings.merged.ui?.theme}" not found.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If no theme is set, check terminal background color
|
||||||
|
const themeName = pickDefaultThemeName(
|
||||||
|
terminalBackground,
|
||||||
|
themeManager.getAllThemes(),
|
||||||
|
DEFAULT_THEME.name,
|
||||||
|
'Default Light',
|
||||||
|
);
|
||||||
|
themeManager.setActiveTheme(themeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
config.setTerminalBackground(terminalBackground);
|
||||||
|
|
||||||
|
if (terminalBackground !== undefined) {
|
||||||
|
const currentTheme = themeManager.getActiveTheme();
|
||||||
|
if (currentTheme.type !== 'ansi' && currentTheme.type !== 'custom') {
|
||||||
|
const backgroundType =
|
||||||
|
getThemeTypeFromBackgroundColor(terminalBackground);
|
||||||
|
if (backgroundType && currentTheme.type !== backgroundType) {
|
||||||
|
coreEvents.emitFeedback(
|
||||||
|
'warning',
|
||||||
|
`Theme '${currentTheme.name}' (${currentTheme.type}) might look incorrect on your ${backgroundType} terminal background. Type /theme to change theme.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return terminalBackground;
|
||||||
|
}
|
||||||
@@ -450,6 +450,7 @@ export class Config {
|
|||||||
|
|
||||||
private readonly experimentalJitContext: boolean;
|
private readonly experimentalJitContext: boolean;
|
||||||
private contextManager?: ContextManager;
|
private contextManager?: ContextManager;
|
||||||
|
private terminalBackground: string | undefined = undefined;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
this.sessionId = params.sessionId;
|
this.sessionId = params.sessionId;
|
||||||
@@ -820,6 +821,14 @@ export class Config {
|
|||||||
this.sessionId = sessionId;
|
this.sessionId = sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTerminalBackground(terminalBackground: string | undefined): void {
|
||||||
|
this.terminalBackground = terminalBackground;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTerminalBackground(): string | undefined {
|
||||||
|
return this.terminalBackground;
|
||||||
|
}
|
||||||
|
|
||||||
shouldLoadMemoryFromIncludeDirectories(): boolean {
|
shouldLoadMemoryFromIncludeDirectories(): boolean {
|
||||||
return this.loadMemoryFromIncludeDirectories;
|
return this.loadMemoryFromIncludeDirectories;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user