diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 2784c5694a..9e0aa860c4 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -106,6 +106,30 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { enterAlternateScreen: vi.fn(), disableLineWrapping: vi.fn(), getVersion: vi.fn(() => Promise.resolve('1.0.0')), + detectTerminalEnvironment: vi.fn().mockReturnValue({ + isTmux: false, + isJetBrains: false, + isWindowsTerminal: false, + isVSCode: false, + isITerm2: false, + isGhostty: false, + isAppleTerminal: false, + isWindows10: false, + supports256Colors: true, + supportsTrueColor: true, + supportsKeyboardProtocol: true, + }), + getTerminalCapabilities: vi.fn().mockReturnValue({ + capabilities: { + supportsAltBuffer: true, + supportsMouse: true, + supportsReliableBackbufferClear: true, + supportsKeyboardProtocol: true, + }, + warnings: [], + reasons: {}, + }), + supportsKeyboardProtocolHeuristic: vi.fn().mockReturnValue(true), startupProfiler: { start: vi.fn(() => ({ end: vi.fn(), @@ -184,6 +208,7 @@ vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ terminalCapabilityManager: { detectCapabilities: vi.fn(), getTerminalBackgroundColor: vi.fn(), + isKeyboardProtocolSupported: vi.fn().mockReturnValue(false), }, })); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 88f9f404cd..a5dd46f559 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -15,6 +15,7 @@ import { createHash } from 'node:crypto'; import v8 from 'node:v8'; import os from 'node:os'; import dns from 'node:dns'; +import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js'; import { start_sandbox } from './utils/sandbox.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; import { @@ -690,6 +691,8 @@ export async function main() { })), ...(await getUserStartupWarnings(settings.merged, undefined, { isAlternateBuffer: useAlternateBuffer, + supportsKeyboardProtocol: + terminalCapabilityManager.isKeyboardProtocolSupported(), })), ]; diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts index a161b2aa1b..27a9b62384 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -270,6 +270,10 @@ export class TerminalCapabilityManager { return this.kittyEnabled; } + isKeyboardProtocolSupported(): boolean { + return this.kittySupported || this.modifyOtherKeysSupported; + } + supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean { if (env['WT_SESSION']) { return false; diff --git a/packages/cli/src/utils/userStartupWarnings.test.ts b/packages/cli/src/utils/userStartupWarnings.test.ts index 41ed061166..7b0404fcc2 100644 --- a/packages/cli/src/utils/userStartupWarnings.test.ts +++ b/packages/cli/src/utils/userStartupWarnings.test.ts @@ -174,5 +174,20 @@ describe('getUserStartupWarnings', () => { ); expect(warnings).not.toContainEqual(compWarning); }); + + it('should pass options to getCompatibilityWarnings', async () => { + const projectDir = path.join(testRootDir, 'project'); + await fs.mkdir(projectDir); + + await getUserStartupWarnings({}, projectDir, { + isAlternateBuffer: true, + supportsKeyboardProtocol: true, + }); + + expect(getCompatibilityWarnings).toHaveBeenCalledWith({ + isAlternateBuffer: true, + supportsKeyboardProtocol: true, + }); + }); }); }); diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index 6174e6c420..921675bb29 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -88,7 +88,10 @@ const WARNING_CHECKS: readonly WarningCheck[] = [ export async function getUserStartupWarnings( settings: Settings, workspaceRoot: string = process.cwd(), - options?: { isAlternateBuffer?: boolean }, + options?: { + isAlternateBuffer?: boolean; + supportsKeyboardProtocol?: boolean; + }, ): Promise { const results = await Promise.all( WARNING_CHECKS.map(async (check) => { @@ -109,6 +112,7 @@ export async function getUserStartupWarnings( warnings.push( ...getCompatibilityWarnings({ isAlternateBuffer: options?.isAlternateBuffer, + supportsKeyboardProtocol: options?.supportsKeyboardProtocol, }), ); } diff --git a/packages/core/src/utils/compatibility.test.ts b/packages/core/src/utils/compatibility.test.ts index faf0dd579d..5d6c90eedb 100644 --- a/packages/core/src/utils/compatibility.test.ts +++ b/packages/core/src/utils/compatibility.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import os from 'node:os'; import { isWindows10, @@ -13,6 +13,8 @@ import { supportsTrueColor, getCompatibilityWarnings, WarningPriority, + isTmux, + supportsKeyboardProtocolHeuristic, } from './compatibility.js'; vi.mock('node:os', () => ({ @@ -24,286 +26,134 @@ vi.mock('node:os', () => ({ describe('compatibility', () => { const originalGetColorDepth = process.stdout.getColorDepth; + const originalIsTTY = process.stdout.isTTY; afterEach(() => { process.stdout.getColorDepth = originalGetColorDepth; + process.stdout.isTTY = originalIsTTY; vi.restoreAllMocks(); vi.unstubAllEnvs(); }); describe('isWindows10', () => { - it.each<{ - platform: NodeJS.Platform; - release: string; - expected: boolean; - desc: string; - }>([ - { - platform: 'win32', - release: '10.0.19041', - expected: true, - desc: 'Windows 10 (build < 22000)', - }, - { - platform: 'win32', - release: '10.0.22000', - expected: false, - desc: 'Windows 11 (build >= 22000)', - }, - { - platform: 'darwin', - release: '20.6.0', - expected: false, - desc: 'non-Windows platforms', - }, - ])( - 'should return $expected for $desc', - ({ platform, release, expected }) => { - vi.mocked(os.platform).mockReturnValue(platform); - vi.mocked(os.release).mockReturnValue(release); - expect(isWindows10()).toBe(expected); - }, - ); + it('should return true for Windows 10', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(os.release).mockReturnValue('10.0.19041'); + expect(isWindows10()).toBe(true); + }); + + it('should return false for Windows 11', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + vi.mocked(os.release).mockReturnValue('10.0.22000'); + expect(isWindows10()).toBe(false); + }); + + it('should return false for non-Windows', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + expect(isWindows10()).toBe(false); + }); }); describe('isJetBrainsTerminal', () => { - it.each<{ env: string; expected: boolean; desc: string }>([ - { - env: 'JetBrains-JediTerm', - expected: true, - desc: 'TERMINAL_EMULATOR is JetBrains-JediTerm', - }, - { env: 'something-else', expected: false, desc: 'other terminals' }, - { env: '', expected: false, desc: 'TERMINAL_EMULATOR is not set' }, - ])('should return $expected when $desc', ({ env, expected }) => { - vi.stubEnv('TERMINAL_EMULATOR', env); - expect(isJetBrainsTerminal()).toBe(expected); + it('should detect JetBrains terminal via env var', () => { + vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); + expect(isJetBrainsTerminal()).toBe(true); + }); + }); + + describe('isTmux', () => { + it('should detect tmux via TMUX env var', () => { + vi.stubEnv('TMUX', '/tmp/tmux-1000/default,123,0'); + expect(isTmux()).toBe(true); }); }); describe('supports256Colors', () => { - it.each<{ - depth: number; - term?: string; - expected: boolean; - desc: string; - }>([ - { - depth: 8, - term: undefined, - expected: true, - desc: 'getColorDepth returns >= 8', - }, - { - depth: 4, - term: 'xterm-256color', - expected: true, - desc: 'TERM contains 256color', - }, - { - depth: 4, - term: 'xterm', - expected: false, - desc: '256 colors are not supported', - }, - ])('should return $expected when $desc', ({ depth, term, expected }) => { - process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); - if (term !== undefined) { - vi.stubEnv('TERM', term); - } - expect(supports256Colors()).toBe(expected); + it('should return true if getColorDepth returns 8', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(8); + expect(supports256Colors()).toBe(true); + }); + + it('should return true if TERM includes 256color', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(4); + vi.stubEnv('TERM', 'xterm-256color'); + expect(supports256Colors()).toBe(true); }); }); describe('supportsTrueColor', () => { - it.each<{ - colorterm: string; - depth: number; - expected: boolean; - desc: string; - }>([ - { - colorterm: 'truecolor', - depth: 8, - expected: true, - desc: 'COLORTERM is truecolor', - }, - { - colorterm: '24bit', - depth: 8, - expected: true, - desc: 'COLORTERM is 24bit', - }, - { - colorterm: '', - depth: 24, - expected: true, - desc: 'getColorDepth returns >= 24', - }, - { - colorterm: '', - depth: 8, - expected: false, - desc: 'true color is not supported', - }, - ])( - 'should return $expected when $desc', - ({ colorterm, depth, expected }) => { - vi.stubEnv('COLORTERM', colorterm); - process.stdout.getColorDepth = vi.fn().mockReturnValue(depth); - expect(supportsTrueColor()).toBe(expected); - }, - ); + it('should return true if COLORTERM is truecolor', () => { + vi.stubEnv('COLORTERM', 'truecolor'); + expect(supportsTrueColor()).toBe(true); + }); + + it('should return true if getColorDepth returns 24', () => { + process.stdout.getColorDepth = vi.fn().mockReturnValue(24); + expect(supportsTrueColor()).toBe(true); + }); + }); + + describe('supportsKeyboardProtocolHeuristic', () => { + it('should return true for Ghostty', () => { + vi.stubEnv('TERM_PROGRAM', 'ghostty'); + expect(supportsKeyboardProtocolHeuristic()).toBe(true); + }); + + it('should return false for Apple Terminal', () => { + vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal'); + expect(supportsKeyboardProtocolHeuristic()).toBe(false); + }); }); describe('getCompatibilityWarnings', () => { - beforeEach(() => { - // Default to supporting true color to keep existing tests simple - vi.stubEnv('COLORTERM', 'truecolor'); - process.stdout.getColorDepth = vi.fn().mockReturnValue(24); - }); - it('should return Windows 10 warning when detected', () => { vi.mocked(os.platform).mockReturnValue('win32'); vi.mocked(os.release).mockReturnValue('10.0.19041'); - vi.stubEnv('TERMINAL_EMULATOR', ''); const warnings = getCompatibilityWarnings(); expect(warnings).toContainEqual( expect.objectContaining({ id: 'windows-10', - message: expect.stringContaining('Windows 10 detected'), - }), - ); - }); - - it.each<{ - platform: NodeJS.Platform; - release: string; - externalTerminal: string; - desc: string; - }>([ - { - platform: 'darwin', - release: '20.6.0', - externalTerminal: 'iTerm2 or Ghostty', - desc: 'macOS', - }, - { - platform: 'win32', - release: '10.0.22000', - externalTerminal: 'Windows Terminal', - desc: 'Windows', - }, // Valid Windows 11 release to not trigger the Windows 10 warning - { - platform: 'linux', - release: '5.10.0', - externalTerminal: 'Ghostty', - desc: 'Linux', - }, - ])( - 'should return JetBrains warning when detected and in alternate buffer ($desc)', - ({ platform, release, externalTerminal }) => { - vi.mocked(os.platform).mockReturnValue(platform); - vi.mocked(os.release).mockReturnValue(release); - vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - - const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); - expect(warnings).toContainEqual( - expect.objectContaining({ - id: 'jetbrains-terminal', - message: expect.stringContaining( - `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal (e.g., ${externalTerminal}) is recommended.`, - ), - priority: WarningPriority.High, - }), - ); - }, - ); - - it('should not return JetBrains warning when detected but NOT in alternate buffer', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); - vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - - const warnings = getCompatibilityWarnings({ isAlternateBuffer: false }); - expect( - warnings.find((w) => w.id === 'jetbrains-terminal'), - ).toBeUndefined(); - }); - - it('should return 256-color warning when 256 colors are not supported', () => { - vi.mocked(os.platform).mockReturnValue('linux'); - vi.stubEnv('TERMINAL_EMULATOR', ''); - vi.stubEnv('COLORTERM', ''); - vi.stubEnv('TERM', 'xterm'); - process.stdout.getColorDepth = vi.fn().mockReturnValue(4); - - const warnings = getCompatibilityWarnings(); - expect(warnings).toContainEqual( - expect.objectContaining({ - id: '256-color', - message: expect.stringContaining('256-color support not detected'), priority: WarningPriority.High, }), ); - // Should NOT show true-color warning if 256-color warning is shown - expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); }); - it('should return true color warning when 256 colors are supported but true color is not, and not Apple Terminal', () => { - vi.mocked(os.platform).mockReturnValue('linux'); - vi.stubEnv('TERMINAL_EMULATOR', ''); - vi.stubEnv('COLORTERM', ''); - vi.stubEnv('TERM_PROGRAM', 'xterm'); - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); + it('should return JetBrains warning when detected and in alt buffer', () => { + vi.mocked(os.platform).mockReturnValue('darwin'); + vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); + const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'jetbrains-terminal', + priority: WarningPriority.High, + }), + ); + }); + + it('should return tmux warning', () => { + vi.stubEnv('TMUX', '/tmp/tmux-1000/default,123,0'); const warnings = getCompatibilityWarnings(); expect(warnings).toContainEqual( expect.objectContaining({ - id: 'true-color', - message: expect.stringContaining( - 'True color (24-bit) support not detected', - ), + id: 'tmux-mouse-support', priority: WarningPriority.Low, }), ); }); - it('should NOT return true color warning for Apple Terminal', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); - vi.stubEnv('TERMINAL_EMULATOR', ''); - vi.stubEnv('COLORTERM', ''); + it('should return keyboard protocol warning', () => { vi.stubEnv('TERM_PROGRAM', 'Apple_Terminal'); - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - - const warnings = getCompatibilityWarnings(); - expect(warnings.find((w) => w.id === 'true-color')).toBeUndefined(); - }); - - it('should return all warnings when all are detected', () => { - vi.mocked(os.platform).mockReturnValue('win32'); - vi.mocked(os.release).mockReturnValue('10.0.19041'); - vi.stubEnv('TERMINAL_EMULATOR', 'JetBrains-JediTerm'); - vi.stubEnv('COLORTERM', ''); - vi.stubEnv('TERM_PROGRAM', 'xterm'); - process.stdout.getColorDepth = vi.fn().mockReturnValue(8); - - const warnings = getCompatibilityWarnings({ isAlternateBuffer: true }); - expect(warnings).toHaveLength(3); - expect(warnings[0].message).toContain('Windows 10 detected'); - expect(warnings[1].message).toContain('JetBrains'); - expect(warnings[2].message).toContain( - 'True color (24-bit) support not detected', + const warnings = getCompatibilityWarnings({ + supportsKeyboardProtocol: false, + }); + expect(warnings).toContainEqual( + expect.objectContaining({ + id: 'keyboard-protocol', + priority: WarningPriority.Low, + }), ); }); - - it('should return no warnings in a standard environment with true color', () => { - vi.mocked(os.platform).mockReturnValue('darwin'); - vi.stubEnv('TERMINAL_EMULATOR', ''); - vi.stubEnv('COLORTERM', 'truecolor'); - - const warnings = getCompatibilityWarnings(); - expect(warnings).toHaveLength(0); - }); }); }); diff --git a/packages/core/src/utils/compatibility.ts b/packages/core/src/utils/compatibility.ts index 15b2ae24b4..c88fd17179 100644 --- a/packages/core/src/utils/compatibility.ts +++ b/packages/core/src/utils/compatibility.ts @@ -8,7 +8,6 @@ import os from 'node:os'; /** * Detects if the current OS is Windows 10. - * Windows 11 also reports as version 10.0, but with build numbers >= 22000. */ export function isWindows10(): boolean { if (os.platform() !== 'win32') { @@ -27,7 +26,12 @@ export function isWindows10(): boolean { * Detects if the current terminal is a JetBrains-based IDE terminal. */ export function isJetBrainsTerminal(): boolean { - return process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm'; + return ( + process.env['TERMINAL_EMULATOR'] === 'JetBrains-JediTerm' || + process.env['TERM_PROGRAM'] === 'JetBrains-JediTerm' || + !!process.env['IDEA_INITIAL_DIRECTORY'] || + !!process.env['JETBRAINS_IDE'] + ); } /** @@ -37,6 +41,44 @@ export function isAppleTerminal(): boolean { return process.env['TERM_PROGRAM'] === 'Apple_Terminal'; } +/** + * Detects if the current terminal is VS Code. + */ +export function isVSCode(): boolean { + return process.env['TERM_PROGRAM'] === 'vscode'; +} + +/** + * Detects if the current terminal is iTerm2. + */ +export function isITerm2(): boolean { + return process.env['TERM_PROGRAM'] === 'iTerm.app'; +} + +/** + * Detects if the current terminal is Ghostty. + */ +export function isGhostty(): boolean { + return ( + process.env['TERM_PROGRAM'] === 'ghostty' || + !!process.env['GHOSTTY_BIN_DIR'] + ); +} + +/** + * Detects if running inside tmux. + */ +export function isTmux(): boolean { + return !!process.env['TMUX'] || (process.env['TERM'] || '').includes('tmux'); +} + +/** + * Detects if the current terminal is Windows Terminal. + */ +export function isWindowsTerminal(): boolean { + return !!process.env['WT_SESSION']; +} + /** * Detects if the current terminal supports 256 colors (8-bit). */ @@ -75,6 +117,13 @@ export function supportsTrueColor(): boolean { return false; } +/** + * Heuristic for keyboard protocol support based on terminal identity. + */ +export function supportsKeyboardProtocolHeuristic(): boolean { + return isGhostty() || isITerm2() || isVSCode() || isWindowsTerminal(); +} + export enum WarningPriority { Low = 'low', High = 'high', @@ -91,6 +140,7 @@ export interface StartupWarning { */ export function getCompatibilityWarnings(options?: { isAlternateBuffer?: boolean; + supportsKeyboardProtocol?: boolean; }): StartupWarning[] { const warnings: StartupWarning[] = []; @@ -114,11 +164,20 @@ export function getCompatibilityWarnings(options?: { warnings.push({ id: 'jetbrains-terminal', - message: `Warning: JetBrains mouse scrolling is unreliable. Disabling alternate buffer mode in settings or using an external terminal${suggestedTerminals} is recommended.`, + message: `Warning: JetBrains mouse scrolling is unreliable with alternate buffer enabled. Using an external terminal${suggestedTerminals} or disabling alternate buffer in settings is recommended.`, priority: WarningPriority.High, }); } + if (isTmux()) { + warnings.push({ + id: 'tmux-mouse-support', + message: + 'Warning: Running inside tmux. For the best experience (including mouse scrolling), ensure "set -g mouse on" is enabled in your tmux configuration.', + priority: WarningPriority.Low, + }); + } + if (!supports256Colors()) { warnings.push({ id: '256-color', @@ -126,7 +185,13 @@ export function getCompatibilityWarnings(options?: { 'Warning: 256-color support not detected. Using a terminal with at least 256-color support is recommended for a better visual experience.', priority: WarningPriority.High, }); - } else if (!supportsTrueColor() && !isAppleTerminal()) { + } else if ( + !supportsTrueColor() && + !isITerm2() && + !isVSCode() && + !isGhostty() && + !isAppleTerminal() + ) { warnings.push({ id: 'true-color', message: @@ -135,5 +200,23 @@ export function getCompatibilityWarnings(options?: { }); } + const hasKeyboardProtocol = + options?.supportsKeyboardProtocol ?? supportsKeyboardProtocolHeuristic(); + + if (!hasKeyboardProtocol) { + const suggestion = + os.platform() === 'darwin' + ? 'iTerm2 or Ghostty' + : os.platform() === 'win32' + ? 'Windows Terminal' + : 'Ghostty'; + + warnings.push({ + id: 'keyboard-protocol', + message: `Warning: Advanced keyboard features (like Shift+Enter for newlines) are not supported in this terminal. Consider using ${suggestion} for a better experience.`, + priority: WarningPriority.Low, + }); + } + return warnings; }