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:
co-authored by
Claude Opus 4.6
parent
d98ff7d19c
commit
f399cd8ce2
@@ -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
|
||||
|
||||
@@ -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.)
|
||||
|
||||
Reference in New Issue
Block a user