feat(terminal): integrate WebGL context manager for GPU-accelerated rendering

Wire up the existing webgl-context-manager into the terminal rendering
pipeline with a user-configurable GPU Acceleration setting (auto/on/off).
WebGL gives 3-5x rendering performance for terminals while falling back
gracefully on unsupported browsers (Safari, some Linux+NVIDIA setups).

- Add GpuAcceleration type and gpuAcceleration field to AppSettings
- Add GPU Acceleration dropdown in Display Settings (i18n: en + fr)
- Register/acquire WebGL context after xterm.open(), unregister on dispose
- Respect user setting: 'off' skips WebGL, 'auto'/'on' acquires context
- Add 13 tests covering WebGL lifecycle and DisplaySettings GPU dropdown

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
AndyMik90
2026-02-17 15:32:43 +01:00
co-authored by Claude Opus 4.6
parent d98ff7d19c
commit f399cd8ce2
8 changed files with 407 additions and 2 deletions
@@ -6,7 +6,7 @@ import { Label } from '../ui/label';
import { SettingsSection } from './SettingsSection';
import { useSettingsStore } from '../../stores/settings-store';
import { UI_SCALE_MIN, UI_SCALE_MAX, UI_SCALE_DEFAULT, UI_SCALE_STEP } from '../../../shared/constants';
import type { AppSettings } from '../../../shared/types';
import type { AppSettings, GpuAcceleration } from '../../../shared/types';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
interface DisplaySettingsProps {
@@ -274,6 +274,42 @@ export function DisplaySettings({ settings, onSettingsChange }: DisplaySettingsP
</Select>
</div>
</div>
{/* GPU Acceleration Setting */}
<div className="space-y-3">
<div className="flex items-center justify-between max-w-md">
<div className="space-y-1">
<Label htmlFor="gpuAcceleration" className="text-sm font-medium text-foreground">
{t('gpuAcceleration.label')}
</Label>
<p className="text-sm text-muted-foreground">
{t('gpuAcceleration.description')}
</p>
</div>
<Select
value={settings.gpuAcceleration || 'auto'}
onValueChange={(value) => onSettingsChange({
...settings,
gpuAcceleration: value as GpuAcceleration
})}
>
<SelectTrigger id="gpuAcceleration" className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-60 overflow-y-auto">
<SelectItem value="auto">
{t('gpuAcceleration.auto')}
</SelectItem>
<SelectItem value="on">
{t('gpuAcceleration.on')}
</SelectItem>
<SelectItem value="off">
{t('gpuAcceleration.off')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
</SettingsSection>
);
@@ -0,0 +1,151 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import '../../../../shared/i18n';
import { DisplaySettings } from '../DisplaySettings';
import type { AppSettings } from '../../../../shared/types';
// Mock the settings store
vi.mock('../../../stores/settings-store', () => ({
useSettingsStore: vi.fn(() => ({
updateSettings: vi.fn()
}))
}));
// Track onValueChange callbacks per Select instance
let selectCallbacks: Array<(v: string) => void> = [];
// Mock Radix Select to make it testable in jsdom (portals don't work in jsdom)
vi.mock('../../ui/select', () => {
return {
Select: ({ value, onValueChange, children }: { value: string; onValueChange: (v: string) => void; children: React.ReactNode }) => {
selectCallbacks.push(onValueChange);
const idx = selectCallbacks.length - 1;
return <div data-testid={`select-root-${idx}`} data-value={value}>{children}</div>;
},
SelectTrigger: ({ id, children }: { id?: string; className?: string; children: React.ReactNode }) => (
<button data-testid={`select-trigger-${id || 'unknown'}`}>{children}</button>
),
SelectValue: () => null,
SelectContent: ({ children }: { className?: string; children: React.ReactNode }) => (
<div data-testid="select-content">{children}</div>
),
SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => (
<div role="option" data-testid={`select-item-${value}`} data-value={value}>
{children}
</div>
)
};
});
const defaultSettings: AppSettings = {
uiScale: 100,
logOrder: 'chronological',
gpuAcceleration: 'auto'
} as AppSettings;
describe('DisplaySettings - GPU Acceleration Dropdown', () => {
let mockOnSettingsChange: (settings: AppSettings) => void;
beforeEach(() => {
vi.clearAllMocks();
selectCallbacks = [];
mockOnSettingsChange = vi.fn();
});
it('should render the GPU acceleration dropdown with all 3 options', () => {
render(
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
);
expect(screen.getByText('GPU Acceleration')).toBeInTheDocument();
expect(screen.getByTestId('select-item-auto')).toBeInTheDocument();
expect(screen.getByTestId('select-item-on')).toBeInTheDocument();
expect(screen.getByTestId('select-item-off')).toBeInTheDocument();
});
it('should display the correct translated labels for each option', () => {
render(
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
);
expect(screen.getByText('Auto (recommended)')).toBeInTheDocument();
expect(screen.getByText('Always on')).toBeInTheDocument();
expect(screen.getByText('Off')).toBeInTheDocument();
});
it('should display the current GPU acceleration value from settings', () => {
const settingsWithOn: AppSettings = { ...defaultSettings, gpuAcceleration: 'on' };
const { container } = render(
<DisplaySettings settings={settingsWithOn} onSettingsChange={mockOnSettingsChange} />
);
// The GPU acceleration select is the second Select rendered (index 1, after logOrder)
const gpuSelect = container.querySelector('[data-testid="select-root-1"]');
expect(gpuSelect).toHaveAttribute('data-value', 'on');
});
it('should default to "auto" when gpuAcceleration is not set', () => {
const settingsWithoutGpu: AppSettings = { ...defaultSettings, gpuAcceleration: undefined };
const { container } = render(
<DisplaySettings settings={settingsWithoutGpu} onSettingsChange={mockOnSettingsChange} />
);
const gpuSelect = container.querySelector('[data-testid="select-root-1"]');
expect(gpuSelect).toHaveAttribute('data-value', 'auto');
});
it('should call onSettingsChange with gpuAcceleration "on" when selected', () => {
render(
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
);
// selectCallbacks[1] is the GPU acceleration Select's onValueChange
selectCallbacks[1]('on');
expect(mockOnSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ gpuAcceleration: 'on' })
);
});
it('should call onSettingsChange with gpuAcceleration "off" when selected', () => {
render(
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
);
selectCallbacks[1]('off');
expect(mockOnSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ gpuAcceleration: 'off' })
);
});
it('should call onSettingsChange with gpuAcceleration "auto" when selected', () => {
const settingsWithOff: AppSettings = { ...defaultSettings, gpuAcceleration: 'off' };
render(
<DisplaySettings settings={settingsWithOff} onSettingsChange={mockOnSettingsChange} />
);
selectCallbacks[1]('auto');
expect(mockOnSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ gpuAcceleration: 'auto' })
);
});
it('should render the GPU acceleration description text', () => {
render(
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
);
expect(
screen.getByText('Use WebGL for terminal rendering (faster with many terminals)')
).toBeInTheDocument();
});
});
@@ -66,11 +66,35 @@ vi.mock('@xterm/addon-serialize', () => ({
vi.mock('../../../../lib/terminal-buffer-manager', () => ({
terminalBufferManager: {
get: vi.fn(() => ''),
getAndClear: vi.fn(() => ''),
set: vi.fn(),
clear: vi.fn()
}
}));
// Mock WebGL context manager
const mockWebglRegister = vi.fn();
const mockWebglAcquire = vi.fn();
const mockWebglUnregister = vi.fn();
vi.mock('../../../lib/webgl-context-manager', () => ({
webglContextManager: {
register: (...args: unknown[]) => mockWebglRegister(...args),
acquire: (...args: unknown[]) => mockWebglAcquire(...args),
unregister: (...args: unknown[]) => mockWebglUnregister(...args),
}
}));
// Mock settings store (for gpuAcceleration setting)
const mockSettingsStoreState = {
settings: { gpuAcceleration: 'auto' as string | undefined }
};
vi.mock('../../../stores/settings-store', () => ({
useSettingsStore: Object.assign(vi.fn(), {
getState: () => mockSettingsStoreState,
subscribe: vi.fn(() => vi.fn()),
})
}));
// Mock navigator.platform for platform detection
const originalNavigatorPlatform = navigator.platform;
@@ -837,3 +861,163 @@ describe('useXterm keyboard handlers', () => {
});
});
});
describe('useXterm WebGL context management', () => {
// Mock requestAnimationFrame for jsdom environment
const originalRequestAnimationFrame = global.requestAnimationFrame;
const originalCancelAnimationFrame = global.cancelAnimationFrame;
beforeAll(() => {
global.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number);
global.cancelAnimationFrame = vi.fn((id: number) => clearTimeout(id));
});
afterAll(() => {
global.requestAnimationFrame = originalRequestAnimationFrame;
global.cancelAnimationFrame = originalCancelAnimationFrame;
});
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
// Reset gpuAcceleration to default
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(function() {
return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() };
});
// Mock window.electronAPI
(window as unknown as { electronAPI: unknown }).electronAPI = {
sendTerminalInput: vi.fn(),
openExternal: vi.fn(),
};
});
afterEach(() => {
vi.clearAllTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
/**
* Helper to render useXterm and wait for initialization
*/
async function renderUseXterm(terminalId = 'test-webgl-terminal') {
// Set up XTerm mock with dispose tracking
const mockDispose = vi.fn();
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
hasSelection: vi.fn(() => false),
getSelection: vi.fn(() => ''),
paste: vi.fn(),
input: vi.fn(),
onData: vi.fn(),
onResize: vi.fn(),
dispose: mockDispose,
write: vi.fn(),
cols: 80,
rows: 24,
options: {
cursorBlink: true,
cursorStyle: 'block',
fontSize: 14,
fontFamily: 'monospace',
fontWeight: 'normal',
lineHeight: 1,
letterSpacing: 0,
theme: { cursorAccent: '#000000' },
scrollback: 1000
},
refresh: vi.fn()
};
});
const { FitAddon } = await import('@xterm/addon-fit');
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn(), dispose: vi.fn() };
});
const { WebLinksAddon } = await import('@xterm/addon-web-links');
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
const { SerializeAddon } = await import('@xterm/addon-serialize');
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return { serialize: vi.fn(() => ''), dispose: vi.fn() };
});
let disposeHook: (() => void) | null = null;
const TestWrapper = () => {
const result = useXterm({ terminalId });
// Expose dispose via ref so tests can call it
disposeHook = result.dispose;
return React.createElement('div', { ref: result.terminalRef });
};
await act(async () => {
render(React.createElement(TestWrapper));
});
return { disposeHook: () => disposeHook?.() };
}
it('should register and acquire WebGL context when gpuAcceleration is "auto"', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
await renderUseXterm('terminal-auto');
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-auto', expect.anything());
expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-auto');
});
it('should register and acquire WebGL context when gpuAcceleration is "on"', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'on';
await renderUseXterm('terminal-on');
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-on', expect.anything());
expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-on');
});
it('should register but NOT acquire WebGL context when gpuAcceleration is "off"', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'off';
await renderUseXterm('terminal-off');
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-off', expect.anything());
expect(mockWebglAcquire).not.toHaveBeenCalled();
});
it('should unregister WebGL context on terminal disposal', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
const { disposeHook } = await renderUseXterm('terminal-dispose');
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-dispose', expect.anything());
// Dispose the terminal
act(() => {
disposeHook();
});
expect(mockWebglUnregister).toHaveBeenCalledWith('terminal-dispose');
});
it('should fallback to "auto" when gpuAcceleration is undefined (upgrading users)', async () => {
mockSettingsStoreState.settings.gpuAcceleration = undefined;
await renderUseXterm('terminal-undefined');
// When undefined, the ?? 'auto' fallback means acquire should be called
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-undefined', expect.anything());
expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-undefined');
});
});
@@ -10,6 +10,8 @@ import { isWindows as checkIsWindows, isLinux as checkIsLinux } from '../../lib/
import { debounce } from '../../lib/debounce';
import { DEFAULT_TERMINAL_THEME } from '../../lib/terminal-theme';
import { debugLog, debugError } from '../../../shared/utils/debug-logger';
import { webglContextManager } from '../../lib/webgl-context-manager';
import { useSettingsStore } from '../../stores/settings-store';
interface UseXtermOptions {
terminalId: string;
@@ -117,6 +119,14 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
xterm.open(terminalRef.current);
// WebGL acceleration: register terminal and acquire context based on user setting
// Must happen AFTER xterm.open() — xterm.js requires the terminal to be mounted first
const gpuAcceleration = useSettingsStore.getState().settings.gpuAcceleration ?? 'auto';
webglContextManager.register(terminalId, xterm);
if (gpuAcceleration !== 'off') {
webglContextManager.acquire(terminalId);
}
// Platform detection for copy/paste shortcuts
// Use existing os-detection module instead of custom implementation
const isWindows = checkIsWindows();
@@ -552,6 +562,9 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
// Serialize buffer before disposing to preserve ANSI formatting
serializeBuffer();
// Release WebGL context before disposing addons and xterm
webglContextManager.unregister(terminalId);
// Dispose addons explicitly before disposing xterm
// While xterm.dispose() handles loaded addons, explicit disposal ensures
// resources are freed in a predictable order and prevents potential leaks
+3 -1
View File
@@ -67,7 +67,9 @@ export const DEFAULT_APP_SETTINGS = {
// Anonymous error reporting (Sentry) - enabled by default to help improve the app
sentryEnabled: true,
// Auto-name Claude terminals based on initial message (enabled by default)
autoNameClaudeTerminals: true
autoNameClaudeTerminals: true,
// GPU acceleration for terminal rendering (auto = WebGL when supported)
gpuAcceleration: 'auto' as const
};
// ============================================
@@ -192,6 +192,13 @@
"chronological": "Chronological (oldest first)",
"reverseChronological": "Reverse-chronological (newest first)"
},
"gpuAcceleration": {
"label": "GPU Acceleration",
"description": "Use WebGL for terminal rendering (faster with many terminals)",
"auto": "Auto (recommended)",
"on": "Always on",
"off": "Off"
},
"general": {
"otherAgentSettings": "Other Agent Settings",
"otherAgentSettingsDescription": "Additional agent configuration options",
@@ -192,6 +192,13 @@
"chronological": "Chronologique (plus ancien en premier)",
"reverseChronological": "Chronologique inverse (plus récent en premier)"
},
"gpuAcceleration": {
"label": "Accélération GPU",
"description": "Utiliser WebGL pour le rendu des terminaux (plus rapide avec plusieurs terminaux)",
"auto": "Auto (recommandé)",
"on": "Toujours activé",
"off": "Désactivé"
},
"general": {
"otherAgentSettings": "Autres paramètres de l'agent",
"otherAgentSettingsDescription": "Options de configuration supplémentaires de l'agent",
@@ -294,8 +294,13 @@ export interface AppSettings {
seenVersionWarnings?: string[];
// Sidebar collapsed state (icons only when true)
sidebarCollapsed?: boolean;
// GPU acceleration for terminal rendering (WebGL)
gpuAcceleration?: GpuAcceleration;
}
// GPU acceleration mode for terminal WebGL rendering
export type GpuAcceleration = 'auto' | 'on' | 'off';
// Auto-Claude Source Environment Configuration (for auto-claude repo .env)
export interface SourceEnvConfig {
// Claude Authentication (required for ideation, roadmap generation, etc.)