Compare commits

...

4 Commits

Author SHA1 Message Date
Spencer b08f9821d8 fix(core): improve terminal heuristics 2026-03-12 21:37:17 +00:00
Spencer bd530829a2 test(core): fix env var leakage in compatibility tests
Clears environment variables like TERMINAL_EMULATOR, JETBRAINS_IDE, TMUX, STY, TERM, and TERM_PROGRAM before running compatibility tests to prevent local developer environments from causing false positives.

refactor(core): standardize warning prefixes

Replaces hardcoded warning emojis with the standard 'Warning:' prefix for consistency with existing warnings.
2026-03-12 20:34:26 +00:00
Spencer a29db295ec test(core): explicitly clear env vars in compatibility tests to prevent leakage 2026-03-12 19:52:07 +00:00
Spencer 5daf14202b fix(core): add actionable warnings for terminal fallbacks (#14426)
Adds detection for tmux, GNU screen, and dumb terminals.
Provides actionable warnings without artificially restricting
features like alternate screen buffer or truecolor overrides.
2026-03-12 19:25:10 +00:00
2 changed files with 246 additions and 58 deletions
+174 -48
View File
@@ -9,6 +9,10 @@ import os from 'node:os';
import {
isWindows10,
isJetBrainsTerminal,
isTmux,
isGnuScreen,
isLowColorTmux,
isDumbTerminal,
supports256Colors,
supportsTrueColor,
getCompatibilityWarnings,
@@ -67,20 +71,108 @@ describe('compatibility', () => {
});
describe('isJetBrainsTerminal', () => {
it.each<{ env: string; expected: boolean; desc: string }>([
it.each<{
env: Record<string, string>;
expected: boolean;
desc: string;
}>([
{
env: 'JetBrains-JediTerm',
env: { TERMINAL_EMULATOR: 'JetBrains-JediTerm' },
expected: true,
desc: 'TERMINAL_EMULATOR is JetBrains-JediTerm',
desc: 'TERMINAL_EMULATOR starts with JetBrains',
},
{ env: 'something-else', expected: false, desc: 'other terminals' },
{ env: '', expected: false, desc: 'TERMINAL_EMULATOR is not set' },
{
env: { JETBRAINS_IDE: 'IntelliJ' },
expected: true,
desc: 'JETBRAINS_IDE is set',
},
{
env: { TERMINAL_EMULATOR: 'xterm' },
expected: false,
desc: 'other terminals',
},
{ env: {}, expected: false, desc: 'no env vars set' },
])('should return $expected when $desc', ({ env, expected }) => {
vi.stubEnv('TERMINAL_EMULATOR', env);
vi.stubEnv('TERMINAL_EMULATOR', '');
vi.stubEnv('JETBRAINS_IDE', '');
for (const [key, value] of Object.entries(env)) {
vi.stubEnv(key, value);
}
expect(isJetBrainsTerminal()).toBe(expected);
});
});
describe('isTmux', () => {
it('should return true when TMUX is set', () => {
vi.stubEnv('TMUX', '/tmp/tmux-1001/default,1425,0');
expect(isTmux()).toBe(true);
});
it('should return false when TMUX is not set', () => {
vi.stubEnv('TMUX', '');
expect(isTmux()).toBe(false);
});
});
describe('isGnuScreen', () => {
it('should return true when STY is set', () => {
vi.stubEnv('STY', '1234.pts-0.host');
expect(isGnuScreen()).toBe(true);
});
it('should return false when STY is not set', () => {
vi.stubEnv('STY', '');
expect(isGnuScreen()).toBe(false);
});
});
describe('isLowColorTmux', () => {
it('should return true when TERM=screen, COLORTERM is not set, and TMUX is set', () => {
vi.stubEnv('TERM', 'screen');
vi.stubEnv('COLORTERM', '');
vi.stubEnv('TMUX', 'dummy');
expect(isLowColorTmux()).toBe(true);
});
it('should return false when TERM=screen, COLORTERM is set, and TMUX is set', () => {
vi.stubEnv('TERM', 'screen');
vi.stubEnv('COLORTERM', 'truecolor');
vi.stubEnv('TMUX', 'dummy');
expect(isLowColorTmux()).toBe(false);
});
it('should return false when TERM=xterm-256color and TMUX is set', () => {
vi.stubEnv('TERM', 'xterm-256color');
vi.stubEnv('COLORTERM', '');
vi.stubEnv('TMUX', 'dummy');
expect(isLowColorTmux()).toBe(false);
});
it('should return false when TMUX is not set', () => {
vi.stubEnv('TERM', 'screen');
vi.stubEnv('COLORTERM', '');
vi.stubEnv('TMUX', '');
expect(isLowColorTmux()).toBe(false);
});
});
describe('isDumbTerminal', () => {
it('should return true when TERM=dumb', () => {
vi.stubEnv('TERM', 'dumb');
expect(isDumbTerminal()).toBe(true);
});
it('should return true when TERM=vt100', () => {
vi.stubEnv('TERM', 'vt100');
expect(isDumbTerminal()).toBe(true);
});
it('should return false when TERM=xterm', () => {
vi.stubEnv('TERM', 'xterm');
expect(isDumbTerminal()).toBe(false);
});
});
describe('supports256Colors', () => {
it.each<{
depth: number;
@@ -110,6 +202,8 @@ describe('compatibility', () => {
process.stdout.getColorDepth = vi.fn().mockReturnValue(depth);
if (term !== undefined) {
vi.stubEnv('TERM', term);
} else {
vi.stubEnv('TERM', '');
}
expect(supports256Colors()).toBe(expected);
});
@@ -158,6 +252,14 @@ describe('compatibility', () => {
describe('getCompatibilityWarnings', () => {
beforeEach(() => {
// Clear out potential local environment variables that might trigger warnings
vi.stubEnv('TERMINAL_EMULATOR', '');
vi.stubEnv('JETBRAINS_IDE', '');
vi.stubEnv('TMUX', '');
vi.stubEnv('STY', '');
vi.stubEnv('TERM', 'xterm-256color'); // Prevent dumb terminal warning
vi.stubEnv('TERM_PROGRAM', '');
// Default to supporting true color to keep existing tests simple
vi.stubEnv('COLORTERM', 'truecolor');
process.stdout.getColorDepth = vi.fn().mockReturnValue(24);
@@ -177,49 +279,73 @@ describe('compatibility', () => {
);
});
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');
it('should return JetBrains warning when detected and in alternate 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',
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,
}),
);
},
);
const warnings = getCompatibilityWarnings({ isAlternateBuffer: true });
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'jetbrains-terminal',
message: expect.stringContaining('JetBrains terminal detected'),
priority: WarningPriority.High,
}),
);
});
it('should return tmux warning when detected and in alternate buffer', () => {
vi.stubEnv('TMUX', '/tmp/tmux-1001/default,1,0');
const warnings = getCompatibilityWarnings({ isAlternateBuffer: true });
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'tmux-alternate-buffer',
message: expect.stringContaining('tmux detected'),
priority: WarningPriority.High,
}),
);
});
it('should return low-color tmux warning when detected', () => {
vi.stubEnv('TERM', 'screen');
vi.stubEnv('COLORTERM', '');
vi.stubEnv('TMUX', 'dummy');
const warnings = getCompatibilityWarnings();
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'low-color-tmux',
message: expect.stringContaining('Limited color support detected'),
priority: WarningPriority.High,
}),
);
});
it('should return GNU screen warning when detected', () => {
vi.stubEnv('STY', '1234.pts-0.host');
const warnings = getCompatibilityWarnings();
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'gnu-screen',
message: expect.stringContaining('GNU screen detected'),
priority: WarningPriority.Low,
}),
);
});
it('should return dumb terminal warning when detected', () => {
vi.stubEnv('TERM', 'dumb');
const warnings = getCompatibilityWarnings();
expect(warnings).toContainEqual(
expect.objectContaining({
id: 'dumb-terminal',
message: expect.stringContaining('Basic terminal detected'),
priority: WarningPriority.High,
}),
);
});
it('should not return JetBrains warning when detected but NOT in alternate buffer', () => {
vi.mocked(os.platform).mockReturnValue('darwin');
+72 -10
View File
@@ -27,7 +27,40 @@ 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';
const env = process.env;
return !!(
env['TERMINAL_EMULATOR']?.startsWith('JetBrains') || env['JETBRAINS_IDE']
);
}
/**
* Detects if the current terminal is running inside tmux.
*/
export function isTmux(): boolean {
return !!process.env['TMUX'];
}
/**
* Detects if the current terminal is running inside GNU screen.
*/
export function isGnuScreen(): boolean {
return !!process.env['STY'];
}
/**
* Detects if the terminal is low-color mode (TERM=screen* with no COLORTERM).
*/
export function isLowColorTmux(): boolean {
const term = process.env['TERM'] || '';
return isTmux() && term.startsWith('screen') && !process.env['COLORTERM'];
}
/**
* Detects if the terminal is a "dumb" terminal.
*/
export function isDumbTerminal(): boolean {
const term = process.env['TERM'] || '';
return term === 'dumb' || term === 'vt100';
}
/**
@@ -104,17 +137,46 @@ export function getCompatibilityWarnings(options?: {
}
if (isJetBrainsTerminal() && options?.isAlternateBuffer) {
const platformTerminals: Partial<Record<NodeJS.Platform, string>> = {
win32: 'Windows Terminal',
darwin: 'iTerm2 or Ghostty',
linux: 'Ghostty',
};
const suggestion = platformTerminals[os.platform()];
const suggestedTerminals = suggestion ? ` (e.g., ${suggestion})` : '';
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 terminal detected — alternate buffer mode may cause scroll wheel issues and rendering artifacts. If you experience problems, disable it in /settings → "Use Alternate Screen Buffer".',
priority: WarningPriority.High,
});
}
if (isTmux() && options?.isAlternateBuffer) {
warnings.push({
id: 'tmux-alternate-buffer',
message:
'Warning: tmux detected — alternate buffer mode may cause unexpected scrollback loss and flickering. If you experience issues, disable it in /settings → "Use Alternate Screen Buffer".\n Tip: Use Ctrl-b [ to access tmux copy mode for scrolling history.',
priority: WarningPriority.High,
});
}
if (isLowColorTmux()) {
warnings.push({
id: 'low-color-tmux',
message:
'Warning: Limited color support detected (TERM=screen). Some visual elements may not render correctly. For better color support in tmux, add to ~/.tmux.conf:\n set -g default-terminal "tmux-256color"\n set -ga terminal-overrides ",*256col*:Tc"',
priority: WarningPriority.High,
});
}
if (isGnuScreen()) {
warnings.push({
id: 'gnu-screen',
message:
'Warning: GNU screen detected. Some keyboard shortcuts and visual features may behave unexpectedly. For the best experience, consider using tmux or running Gemini CLI directly in your terminal.',
priority: WarningPriority.Low,
});
}
if (isDumbTerminal()) {
warnings.push({
id: 'dumb-terminal',
message:
'Warning: Basic terminal detected (TERM=dumb). Visual rendering will be limited. For the best experience, use a terminal emulator with truecolor support.',
priority: WarningPriority.High,
});
}