Merge pull request #1831 from AndyMik90/feature/webgl-context-management

feat(terminal): integrate WebGL context manager for GPU-accelerated rendering
This commit is contained in:
Andy
2026-02-20 11:57:45 +01:00
committed by GitHub
20 changed files with 774 additions and 131 deletions
+44 -2
View File
@@ -38,6 +38,18 @@ log.transports.file.fileName = 'main.log';
// Console transport - always show warnings and errors, debug only in dev mode
log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'warn';
log.transports.console.format = '[{h}:{i}:{s}] [{level}] {text}';
// Guard console transport writes so broken stdio streams do not crash the app.
{
const originalConsoleWriteFn = log.transports.console.writeFn as (...args: unknown[]) => void;
log.transports.console.writeFn = (...args: unknown[]) => {
try {
originalConsoleWriteFn(...args);
} catch (error) {
const err = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
safeStderrWrite(`[app-logger] console transport write failed: ${err}`);
}
};
}
// Determine if this is a beta version
function isBetaVersion(): boolean {
@@ -204,14 +216,44 @@ export const appLog = {
log: (...args: unknown[]) => log.info(...args),
};
/**
* Best-effort stderr fallback used when electron-log itself throws (e.g. EIO).
* Must never throw, especially inside uncaught exception handlers.
*/
function safeStderrWrite(message: string): void {
try {
process.stderr.write(`${message}\n`);
} catch {
// Ignore - nothing else we can safely do here.
}
}
/**
* Log an unhandled error without risking recursive crashes if logger transport fails.
*/
function safeLogUnhandled(prefix: string, value: unknown): void {
try {
log.error(prefix, value);
} catch (loggingError) {
const loggingFailure = loggingError instanceof Error
? `${loggingError.name}: ${loggingError.message}`
: String(loggingError);
const original = value instanceof Error
? (value.stack || `${value.name}: ${value.message}`)
: String(value);
safeStderrWrite(`[app-logger] ${prefix} (logger failed: ${loggingFailure})`);
safeStderrWrite(original);
}
}
// Log unhandled errors
export function setupErrorLogging(): void {
process.on('uncaughtException', (error) => {
log.error('Uncaught exception:', error);
safeLogUnhandled('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason) => {
log.error('Unhandled rejection:', reason);
safeLogUnhandled('Unhandled rejection:', reason);
});
log.info('Error logging initialized');
+11 -1
View File
@@ -49,7 +49,7 @@ import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater';
import { DEFAULT_APP_SETTINGS, IPC_CHANNELS, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE, ADD_TO_DICTIONARY_LABELS } from '../shared/constants';
import { getAppLanguage, initAppLanguage } from './app-language';
import { readSettingsFile } from './settings-utils';
import { setupErrorLogging } from './app-logger';
import { appLog, setupErrorLogging } from './app-logger';
import { initSentryMain } from './sentry';
import { preWarmToolCache } from './cli-tool-manager';
import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager';
@@ -143,6 +143,11 @@ let mainWindow: BrowserWindow | null = null;
let agentManager: AgentManager | null = null;
let terminalManager: TerminalManager | null = null;
// Capture child process exits (renderer/GPU/utility) for crash diagnostics.
app.on('child-process-gone', (_event, details) => {
appLog.error('[main] child-process-gone:', details);
});
// Re-entrancy guard for before-quit handler.
// The first before-quit call pauses quit for async cleanup, then calls app.quit() again.
// The second call sees isQuitting=true and allows quit to proceed immediately.
@@ -214,6 +219,11 @@ function createWindow(): void {
mainWindow?.show();
});
// Capture renderer process crashes/termination reasons for diagnostics.
mainWindow.webContents.on('render-process-gone', (_event, details) => {
appLog.error('[main] render-process-gone:', details);
});
// Configure initial spell check languages with proper fallback logic
// Uses shared constant for consistency with the IPC handler
const defaultLanguage = 'en';
@@ -195,8 +195,20 @@ export class TerminalSessionStore {
* 1. Write to temp file
* 2. Rotate current file to backup
* 3. Rename temp to target (atomic on most filesystems)
*
* If an async write is in progress, defers to the async writer to avoid
* both operations competing for the same temp file (ENOENT race condition).
*/
private save(): void {
// If an async write is in progress, don't write synchronously — the async
// writer shares the same temp file path. Instead, mark a pending write so
// saveAsync() will re-save with the latest in-memory data when it finishes.
if (this.writeInProgress) {
this.writePending = true;
debugLog('[TerminalSessionStore] Deferring sync save — async write in progress');
return;
}
try {
const content = JSON.stringify(this.data, null, 2);
@@ -366,7 +378,9 @@ export class TerminalSessionStore {
private updateSessionInMemory(session: TerminalSession): boolean {
// Check if session was deleted - skip if pending deletion
if (this.pendingDelete.has(session.id)) {
console.warn('[TerminalSessionStore] Skipping save for deleted session:', session.id);
debugLog('[TerminalSessionStore] Skipping save for deleted session:', session.id,
'pendingDelete size:', this.pendingDelete.size,
'all pending IDs:', [...this.pendingDelete].join(', '));
return false;
}
@@ -596,6 +610,27 @@ export class TerminalSessionStore {
return sessions.find(s => s.id === sessionId);
}
/**
* Clear a session ID from pendingDelete, allowing saves to proceed.
*
* Called when a terminal is legitimately re-created with the same ID
* (e.g., worktree switching, terminal restart after exit). Without this,
* the 5-second pendingDelete window blocks session persistence for the
* new terminal.
*/
clearPendingDelete(sessionId: string): void {
if (this.pendingDelete.has(sessionId)) {
this.pendingDelete.delete(sessionId);
// Also clear the cleanup timer since we're explicitly clearing
const timer = this.pendingDeleteTimers.get(sessionId);
if (timer) {
clearTimeout(timer);
this.pendingDeleteTimers.delete(sessionId);
}
debugLog('[TerminalSessionStore] Cleared pendingDelete for re-created terminal:', sessionId);
}
}
/**
* Remove a session (from today's sessions)
*
@@ -606,6 +641,9 @@ export class TerminalSessionStore {
// Mark as pending delete BEFORE modifying data to prevent race condition
// with in-flight saveSessionAsync() calls
this.pendingDelete.add(sessionId);
debugLog('[TerminalSessionStore] removeSession: added to pendingDelete:', sessionId,
'pendingDelete size:', this.pendingDelete.size,
'all pending IDs:', [...this.pendingDelete].join(', '));
const todaySessions = this.getTodaysSessions();
if (todaySessions[projectPath]) {
@@ -627,6 +665,8 @@ export class TerminalSessionStore {
const timer = setTimeout(() => {
this.pendingDelete.delete(sessionId);
this.pendingDeleteTimers.delete(sessionId);
debugLog('[TerminalSessionStore] Cleanup timer fired for:', sessionId,
'removing from pendingDelete. Remaining:', this.pendingDelete.size);
}, 5000);
this.pendingDeleteTimers.set(sessionId, timer);
}
@@ -109,7 +109,16 @@ function mockPlatform(platform: 'win32' | 'darwin' | 'linux') {
/**
* Helper to get platform-specific expectations for PATH prefix
*/
function getPathPrefixExpectation(platform: 'win32' | 'darwin' | 'linux', pathValue: string): string {
function getPathPrefixExpectation(
platform: 'win32' | 'darwin' | 'linux',
pathValue: string,
command: string
): string {
// Absolute executable commands no longer need PATH prefix injection.
if (path.isAbsolute(command)) {
return '';
}
if (platform === 'win32') {
// Windows: set "PATH=value" &&
return `set "PATH=${pathValue}" && `;
@@ -118,6 +127,20 @@ function getPathPrefixExpectation(platform: 'win32' | 'darwin' | 'linux', pathVa
return `PATH='${pathValue}' `;
}
function expectPathPrefix(
written: string,
platform: 'win32' | 'darwin' | 'linux',
pathValue: string,
command: string
): void {
const expectedPrefix = getPathPrefixExpectation(platform, pathValue, command);
if (expectedPrefix) {
expect(written).toContain(expectedPrefix);
} else {
expect(written).not.toContain('PATH=');
}
}
/**
* Helper to get platform-specific expectations for command quoting
*/
@@ -241,7 +264,7 @@ describe('claude-integration-handler', () => {
const written = mockWriteToPty.mock.calls[0][1] as string;
expect(written).toContain(buildCdCommand('/tmp/project'));
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', "/opt/claude bin/claude's");
expect(written).toContain(getQuotedCommand(platform, "/opt/claude bin/claude's"));
expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1');
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
@@ -402,7 +425,7 @@ describe('claude-integration-handler', () => {
expect(written).toContain(histPrefix);
expect(written).toContain(configDir);
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', command);
expect(written).toContain(getQuotedCommand(platform, command));
expect(written).toContain(clearCmd);
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-2');
@@ -436,7 +459,7 @@ describe('claude-integration-handler', () => {
const written = mockWriteToPty.mock.calls[0][1] as string;
expect(written).toContain(getQuotedCommand(platform, command));
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', command);
expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3');
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-3');
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
@@ -460,7 +483,7 @@ describe('claude-integration-handler', () => {
resumeClaude(terminal, 'abc123', () => null);
const resumeCall = mockWriteToPty.mock.calls[0][1] as string;
expect(resumeCall).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
expectPathPrefix(resumeCall, platform, '/opt/claude/bin:/usr/bin', '/opt/claude/bin/claude');
expect(resumeCall).toContain(getQuotedCommand(platform, '/opt/claude/bin/claude') + ' --continue');
expect(resumeCall).not.toContain('--resume');
// sessionId is cleared because --continue doesn't track specific sessions
@@ -656,7 +679,7 @@ describe('invokeClaudeAsync', () => {
const written = mockWriteToPty.mock.calls[0][1] as string;
expect(written).toContain(buildCdCommand('/tmp/project'));
expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin'));
expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', '/opt/claude/bin/claude');
expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1');
expect(mockPersistSession).toHaveBeenCalledWith(terminal);
expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default');
@@ -914,7 +937,8 @@ describe('claude-integration-handler - Helper Functions', () => {
// Use a default terminal name pattern so renaming logic kicks in
const terminal = createMockTerminal({ title: 'Terminal 1' });
const mockWindow = {
webContents: { send: vi.fn() }
isDestroyed: () => false,
webContents: { send: vi.fn(), isDestroyed: () => false }
};
finalizeClaudeInvoke(
@@ -934,7 +958,8 @@ describe('claude-integration-handler - Helper Functions', () => {
// Use a default terminal name pattern so renaming logic kicks in
const terminal = createMockTerminal({ title: 'Terminal 2' });
const mockWindow = {
webContents: { send: vi.fn() }
isDestroyed: () => false,
webContents: { send: vi.fn(), isDestroyed: () => false }
};
finalizeClaudeInvoke(
@@ -955,7 +980,8 @@ describe('claude-integration-handler - Helper Functions', () => {
const terminal = createMockTerminal({ title: 'Terminal 3' });
const mockSend = vi.fn();
const mockWindow = {
webContents: { send: mockSend }
isDestroyed: () => false,
webContents: { send: mockSend, isDestroyed: () => false }
};
finalizeClaudeInvoke(
@@ -980,7 +1006,8 @@ describe('claude-integration-handler - Helper Functions', () => {
const terminal = createMockTerminal({ title: 'Claude' });
const mockSend = vi.fn();
const mockWindow = {
webContents: { send: mockSend }
isDestroyed: () => false,
webContents: { send: mockSend, isDestroyed: () => false }
};
finalizeClaudeInvoke(
@@ -1004,7 +1031,8 @@ describe('claude-integration-handler - Helper Functions', () => {
const terminal = createMockTerminal({ title: 'My Custom Terminal' });
const mockSend = vi.fn();
const mockWindow = {
webContents: { send: mockSend }
isDestroyed: () => false,
webContents: { send: mockSend, isDestroyed: () => false }
};
finalizeClaudeInvoke(
@@ -16,6 +16,7 @@ import { getEmailFromConfigDir } from '../claude-profile/profile-utils';
import * as OutputParser from './output-parser';
import * as SessionHandler from './session-handler';
import * as PtyManager from './pty-manager';
import { safeSendToRenderer } from '../ipc-handlers/utils';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand } from '../../shared/utils/shell-escape';
import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils';
@@ -116,6 +117,19 @@ function normalizePathForBash(envPath: string): string {
return isWindows() ? envPath.replace(/;/g, ':') : envPath;
}
/**
* Determine whether a command already resolves via an absolute executable path.
*
* When true, we should avoid prefixing PATH=... into the typed shell command because:
* 1) PATH is not needed to locate the executable
* 2) very long PATH prefixes create huge echoed command lines that can stress terminal rendering
*/
function isAbsoluteExecutableCommand(command: string): boolean {
const trimmed = command.trim();
if (!trimmed) return false;
return path.isAbsolute(trimmed);
}
/**
* Generate temp file content for OAuth token based on platform
*
@@ -380,11 +394,8 @@ export function finalizeClaudeInvoke(
: 'Claude';
terminal.title = title;
// Notify renderer of title change
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
}
// Notify renderer of title change (use safeSendToRenderer to prevent SIGABRT on disposed frame)
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title);
}
// Persist session if project path is available
@@ -434,18 +445,15 @@ export function handleRateLimit(
const autoSwitchSettings = profileManager.getAutoSwitchSettings();
const bestProfile = profileManager.getBestAvailableProfile(currentProfileId);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, {
terminalId: terminal.id,
resetTime,
detectedAt: new Date().toISOString(),
profileId: currentProfileId,
suggestedProfileId: bestProfile?.id,
suggestedProfileName: bestProfile?.name,
autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit
} as RateLimitEvent);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_RATE_LIMIT, {
terminalId: terminal.id,
resetTime,
detectedAt: new Date().toISOString(),
profileId: currentProfileId,
suggestedProfileId: bestProfile?.id,
suggestedProfileName: bestProfile?.name,
autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit
} as RateLimitEvent);
if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) {
console.warn('[ClaudeIntegration] Auto-switching to profile:', bestProfile.name);
@@ -535,19 +543,16 @@ export function handleOAuthToken(
// Set flag to watch for Claude's ready state (onboarding complete)
terminal.awaitingOnboardingComplete = true;
const win = getWindow();
if (win) {
// needsOnboarding: true tells the UI to show "complete setup" message
// instead of "success" - user should finish Claude's onboarding before closing
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email: emailFromOutput || keychainCreds.email || profile?.email,
success: true,
needsOnboarding: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
// needsOnboarding: true tells the UI to show "complete setup" message
// instead of "success" - user should finish Claude's onboarding before closing
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email: emailFromOutput || keychainCreds.email || profile?.email,
success: true,
needsOnboarding: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
} else {
// Token not in Keychain yet, but profile may still be authenticated via configDir
// Check if profile has valid auth (credentials exist in configDir)
@@ -559,19 +564,16 @@ export function handleOAuthToken(
// Set flag to watch for Claude's ready state (onboarding complete)
terminal.awaitingOnboardingComplete = true;
const win = getWindow();
if (win) {
// needsOnboarding: true tells the UI to show "complete setup" message
// instead of "success" - user should finish Claude's onboarding before closing
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email: emailFromOutput || profile?.email,
success: true,
needsOnboarding: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
// needsOnboarding: true tells the UI to show "complete setup" message
// instead of "success" - user should finish Claude's onboarding before closing
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email: emailFromOutput || profile?.email,
success: true,
needsOnboarding: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
} else {
console.warn('[ClaudeIntegration] Login successful but Keychain token not found and no credentials in configDir - user may need to complete authentication manually');
}
@@ -622,16 +624,13 @@ export function handleOAuthToken(
clearKeychainCache(profile.configDir);
console.warn('[ClaudeIntegration] Profile credentials verified (not caching token):', profileId);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email,
success: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId,
email,
success: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
} else {
console.error('[ClaudeIntegration] Profile not found for OAuth token:', profileId);
}
@@ -645,17 +644,14 @@ export function handleOAuthToken(
// Defensive null check for active profile
if (!activeProfile) {
console.error('[ClaudeIntegration] Failed to update profile: no active profile found');
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: undefined,
email,
success: false,
message: 'No active profile found',
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: undefined,
email,
success: false,
message: 'No active profile found',
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
return;
}
@@ -686,16 +682,13 @@ export function handleOAuthToken(
clearKeychainCache(activeProfile.configDir);
console.warn('[ClaudeIntegration] Active profile credentials verified (not caching token):', activeProfile.name);
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: activeProfile.id,
email,
success: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, {
terminalId: terminal.id,
profileId: activeProfile.id,
email,
success: true,
detectedAt: new Date().toISOString()
} as OAuthTokenEvent);
}
}
@@ -785,14 +778,11 @@ export function handleOnboardingComplete(
}
}
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, {
terminalId: terminal.id,
profileId,
detectedAt: new Date().toISOString()
} as OnboardingCompleteEvent);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, {
terminalId: terminal.id,
profileId,
detectedAt: new Date().toISOString()
} as OnboardingCompleteEvent);
// Trigger immediate usage fetch after successful re-authentication
// This gives the user immediate feedback that their account is working
@@ -845,10 +835,7 @@ export function handleClaudeSessionId(
SessionHandler.updateClaudeSessionId(terminal.projectPath, terminal.id, sessionId);
}
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId);
}
/**
@@ -879,10 +866,7 @@ export function handleClaudeExit(
}
// Notify renderer to update UI
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id);
}
/**
@@ -1109,7 +1093,9 @@ export function invokeClaude(
const cwdCommand = buildCdCommand(cwd, terminal.shellType);
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
? ''
: buildPathPrefix(claudeEnv.PATH || '');
const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId);
debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', {
@@ -1196,7 +1182,9 @@ export function resumeClaude(
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
? ''
: buildPathPrefix(claudeEnv.PATH || '');
// Always use --continue which resumes the most recent session in the current directory.
// This is more reliable than --resume with session IDs since Auto Claude already restores
@@ -1223,10 +1211,7 @@ export function resumeClaude(
// This preserves user-customized names and prevents renaming on every resume
if (shouldAutoRenameTerminal(terminal.title)) {
terminal.title = 'Claude';
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
}
// Persist session
@@ -1316,7 +1301,9 @@ export async function invokeClaudeAsync(
});
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
? ''
: buildPathPrefix(claudeEnv.PATH || '');
const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId);
debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', {
@@ -1413,7 +1400,9 @@ export async function resumeClaudeAsync(
});
const escapedClaudeCmd = escapeShellCommand(claudeCmd);
const pathPrefix = buildPathPrefix(claudeEnv.PATH || '');
const pathPrefix = isAbsoluteExecutableCommand(claudeCmd)
? ''
: buildPathPrefix(claudeEnv.PATH || '');
// Always use --continue which resumes the most recent session in the current directory.
// This is more reliable than --resume with session IDs since Auto Claude already restores
@@ -1444,10 +1433,7 @@ export async function resumeClaudeAsync(
// This preserves user-customized names and prevents renaming on every resume
if (shouldAutoRenameTerminal(terminal.title)) {
terminal.title = 'Claude';
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude');
}
// Persist session (async, fire-and-forget to prevent main process blocking)
+38 -5
View File
@@ -209,18 +209,25 @@ export function setupPtyHandlers(
onExitCallback: (terminal: TerminalProcess) => void
): void {
const { id, pty: ptyProcess } = terminal;
terminal.hasExited = false;
// Handle data from terminal
ptyProcess.onData((data) => {
// Shutdown guard (GitHub #1469): skip processing to avoid accessing
// destroyed BrowserWindow.webContents, which triggers pty.node SIGABRT
if (isShuttingDown) return;
if (terminal.hasExited) return;
// Append to output buffer (limit to 100KB)
terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000);
// Call custom data handler
onDataCallback(terminal, data);
// Call custom data handler. This must never crash the main process:
// parser logic in higher layers can throw on unexpected output.
try {
onDataCallback(terminal, data);
} catch (error) {
debugError('[PtyManager] onData callback failed for terminal:', id, 'error:', error);
}
// Send to renderer with isDestroyed() check to prevent crashes
// when the window is closed during terminal activity
@@ -229,6 +236,9 @@ export function setupPtyHandlers(
// Handle terminal exit
ptyProcess.onExit(({ exitCode }) => {
terminal.hasExited = true;
// Drop any queued writes for this terminal to avoid writing to dead PTYs.
pendingWrites.delete(id);
debugLog('[PtyManager] Terminal exited:', id, 'code:', exitCode);
// Always resolve pending exit promises, even during shutdown
@@ -248,8 +258,13 @@ export function setupPtyHandlers(
// when the window is closed during terminal exit
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_EXIT, id, exitCode);
// Call custom exit handler
onExitCallback(terminal);
// Call custom exit handler. Guard against unexpected exceptions so PTY exit
// handling remains robust and doesn't take down the main process.
try {
onExitCallback(terminal);
} catch (error) {
debugError('[PtyManager] onExit callback failed for terminal:', id, 'error:', error);
}
// Only delete if this is the SAME terminal object (not a newly created one with same ID).
// This prevents a race where destroyTerminal() awaits PTY exit, a new terminal is created
@@ -285,6 +300,11 @@ const pendingWrites = new Map<string, Promise<void>>();
*/
function performWrite(terminal: TerminalProcess, data: string): Promise<void> {
return new Promise((resolve) => {
if (terminal.hasExited) {
resolve();
return;
}
// For large commands, write in chunks to prevent blocking
if (data.length > CHUNKED_WRITE_THRESHOLD) {
debugLog('[PtyManager:writeToPty] Large write detected, using chunked write');
@@ -293,7 +313,7 @@ function performWrite(terminal: TerminalProcess, data: string): Promise<void> {
const writeChunk = () => {
// Check if terminal is still valid before writing
if (!terminal.pty) {
if (!terminal.pty || terminal.hasExited) {
debugError('[PtyManager:writeToPty] Terminal PTY no longer valid, aborting chunked write');
resolve();
return;
@@ -339,6 +359,10 @@ function performWrite(terminal: TerminalProcess, data: string): Promise<void> {
*/
export function writeToPty(terminal: TerminalProcess, data: string): void {
debugLog('[PtyManager:writeToPty] About to write to pty, data length:', data.length);
if (terminal.hasExited) {
debugError('[PtyManager:writeToPty] Skipping write to exited terminal:', terminal.id);
return;
}
// Get the previous write Promise for this terminal (if any)
const previousWrite = pendingWrites.get(terminal.id) || Promise.resolve();
@@ -366,6 +390,11 @@ export function writeToPty(terminal: TerminalProcess, data: string): void {
* @returns true if resize was successful, false otherwise
*/
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): boolean {
if (terminal.hasExited) {
debugError('[PtyManager] Resize skipped for exited terminal:', terminal.id);
return false;
}
// Validate dimensions
if (cols <= 0 || rows <= 0 || !Number.isFinite(cols) || !Number.isFinite(rows)) {
debugError('[PtyManager] Invalid resize dimensions - terminal:', terminal.id, 'cols:', cols, 'rows:', rows);
@@ -394,6 +423,10 @@ export function resizePty(terminal: TerminalProcess, cols: number, rows: number)
export function killPty(terminal: TerminalProcess, waitForExit: true): Promise<void>;
export function killPty(terminal: TerminalProcess, waitForExit?: false): void;
export function killPty(terminal: TerminalProcess, waitForExit?: boolean): Promise<void> | void {
if (terminal.hasExited) {
return waitForExit ? Promise.resolve() : undefined;
}
if (waitForExit) {
const exitPromise = waitForPtyExit(terminal.id);
try {
@@ -225,6 +225,18 @@ export function persistAllSessions(terminals: Map<string, TerminalProcess>): voi
});
}
/**
* Clear a terminal ID from pendingDelete, allowing session saves to proceed.
*
* Must be called when re-creating a terminal with a previously-used ID
* (e.g., worktree switching, terminal restart after shell exit). Without this,
* the pendingDelete guard blocks persistence for the new terminal.
*/
export function clearPendingDelete(terminalId: string): void {
const store = getTerminalSessionStore();
store.clearPendingDelete(terminalId);
}
/**
* Remove a session from persistent storage
*/
@@ -7,6 +7,7 @@ import * as OutputParser from './output-parser';
import * as ClaudeIntegration from './claude-integration-handler';
import type { TerminalProcess, WindowGetter } from './types';
import { IPC_CHANNELS } from '../../shared/constants';
import { safeSendToRenderer } from '../ipc-handlers/utils';
/**
* Event handler callbacks
@@ -109,10 +110,7 @@ export function createEventCallbacks(
ClaudeIntegration.handleOnboardingComplete(terminal, data, getWindow);
},
onClaudeBusyChange: (terminal, isBusy) => {
const win = getWindow();
if (win) {
win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy);
}
safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy);
},
onClaudeExit: (terminal) => {
ClaudeIntegration.handleClaudeExit(terminal, getWindow);
@@ -54,6 +54,13 @@ export async function createTerminal(
return { success: true };
}
// Clear any pendingDelete for this terminal ID. This handles the case where
// a terminal is destroyed and immediately re-created with the same ID (e.g.,
// worktree switching, terminal restart after shell exit). Without this, the
// pendingDelete guard (5-second window) blocks session persistence for the
// new terminal, causing it to be invisible to the session store.
SessionHandler.clearPendingDelete(id);
try {
// For auth terminals, don't inject existing OAuth token - we want a fresh login
const profileEnv = skipOAuthToken ? {} : PtyManager.getActiveProfileEnv();
@@ -101,6 +108,7 @@ export async function createTerminal(
id,
pty: ptyProcess,
isClaudeMode: false,
hasExited: false,
projectPath,
cwd: terminalCwd,
outputBuffer: '',
+2
View File
@@ -28,6 +28,8 @@ export interface TerminalProcess {
shellType?: WindowsShellType;
/** Whether this terminal is waiting for Claude onboarding to complete (login flow) */
awaitingOnboardingComplete?: boolean;
/** Whether PTY has emitted exit; used to avoid writes/resizes on dead PTYs */
hasExited?: boolean;
}
/**
@@ -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,45 @@ 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 || 'off'}
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>
<p className="text-xs text-muted-foreground">
{t('gpuAcceleration.helperText')}
</p>
</div>
</div>
</SettingsSection>
);
@@ -0,0 +1,157 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } 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, keyed by the SelectTrigger id
let selectCallbacks: Map<string, (v: string) => void> = new Map();
let currentSelectCallback: ((v: string) => void) | null = null;
// 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 }) => {
currentSelectCallback = onValueChange;
return <div data-value={value}>{children}</div>;
},
SelectTrigger: ({ id, children }: { id?: string; className?: string; children: React.ReactNode }) => {
if (id && currentSelectCallback) {
selectCallbacks.set(id, currentSelectCallback);
currentSelectCallback = null;
}
return <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 = new Map();
currentSelectCallback = null;
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 (use WebGL when supported)')).toBeInTheDocument();
expect(screen.getByText('Always on')).toBeInTheDocument();
expect(screen.getByText('Off (default)')).toBeInTheDocument();
});
it('should display the current GPU acceleration value from settings', () => {
const settingsWithOn: AppSettings = { ...defaultSettings, gpuAcceleration: 'on' };
render(
<DisplaySettings settings={settingsWithOn} onSettingsChange={mockOnSettingsChange} />
);
// The GPU acceleration select is identified by its trigger id
const gpuTrigger = screen.getByTestId('select-trigger-gpuAcceleration');
const gpuSelect = gpuTrigger.closest('[data-value]');
expect(gpuSelect).toHaveAttribute('data-value', 'on');
});
it('should default to "off" when gpuAcceleration is not set', () => {
const settingsWithoutGpu: AppSettings = { ...defaultSettings, gpuAcceleration: undefined };
render(
<DisplaySettings settings={settingsWithoutGpu} onSettingsChange={mockOnSettingsChange} />
);
const gpuTrigger = screen.getByTestId('select-trigger-gpuAcceleration');
const gpuSelect = gpuTrigger.closest('[data-value]');
expect(gpuSelect).toHaveAttribute('data-value', 'off');
});
it('should call onSettingsChange with gpuAcceleration "on" when selected', () => {
render(
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
);
selectCallbacks.get('gpuAcceleration')!('on');
expect(mockOnSettingsChange).toHaveBeenCalledWith(
expect.objectContaining({ gpuAcceleration: 'on' })
);
});
it('should call onSettingsChange with gpuAcceleration "off" when selected', () => {
render(
<DisplaySettings settings={defaultSettings} onSettingsChange={mockOnSettingsChange} />
);
selectCallbacks.get('gpuAcceleration')!('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.get('gpuAcceleration')!('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 (experimental, 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,186 @@ 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 lazily import and acquire WebGL context when gpuAcceleration is "auto"', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
await renderUseXterm('terminal-auto');
// Flush the dynamic import() promise + microtasks
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-auto', expect.anything());
expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-auto');
});
it('should lazily import and acquire WebGL context when gpuAcceleration is "on"', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'on';
await renderUseXterm('terminal-on');
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-on', expect.anything());
expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-on');
});
it('should NOT import WebGL module at all when gpuAcceleration is "off"', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'off';
await renderUseXterm('terminal-off');
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
// When off, the dynamic import() never fires — no GPU code runs
expect(mockWebglRegister).not.toHaveBeenCalled();
expect(mockWebglAcquire).not.toHaveBeenCalled();
});
it('should unregister WebGL context on terminal disposal', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'auto';
const { disposeHook } = await renderUseXterm('terminal-dispose');
// Flush the dynamic import so the manager ref is populated
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
expect(mockWebglRegister).toHaveBeenCalledWith('terminal-dispose', expect.anything());
// Dispose the terminal
act(() => {
disposeHook();
});
expect(mockWebglUnregister).toHaveBeenCalledWith('terminal-dispose');
});
it('should NOT unregister on disposal when WebGL was never loaded (off)', async () => {
mockSettingsStoreState.settings.gpuAcceleration = 'off';
const { disposeHook } = await renderUseXterm('terminal-off-dispose');
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
// Dispose the terminal
act(() => {
disposeHook();
});
// WebGL was never loaded, so unregister should not be called
expect(mockWebglUnregister).not.toHaveBeenCalled();
});
it('should fallback to "off" when gpuAcceleration is undefined (upgrading users)', async () => {
mockSettingsStoreState.settings.gpuAcceleration = undefined;
await renderUseXterm('terminal-undefined');
await act(async () => { await vi.advanceTimersByTimeAsync(0); });
// When undefined, the ?? 'off' fallback means no WebGL import at all
expect(mockWebglRegister).not.toHaveBeenCalled();
expect(mockWebglAcquire).not.toHaveBeenCalled();
});
});
@@ -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 { useSettingsStore } from '../../stores/settings-store';
import type { WebGLContextManagerType } from '../../lib/webgl-context-manager';
interface UseXtermOptions {
terminalId: string;
@@ -58,6 +60,8 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
const commandBufferRef = useRef<string>('');
const isDisposedRef = useRef<boolean>(false);
const dimensionsReadyCalledRef = useRef<boolean>(false);
// Lazily-loaded WebGL context manager — only populated when gpuAcceleration !== 'off'
const webglManagerRef = useRef<WebGLContextManagerType | null>(null);
const onResizeRef = useRef(onResize);
const [dimensions, setDimensions] = useState<{ cols: number; rows: number }>({ cols: 80, rows: 24 });
@@ -117,6 +121,29 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
xterm.open(terminalRef.current);
// WebGL acceleration: lazily load the WebGL module and acquire a context.
// The dynamic import() ensures NO GPU code (WebGL2 probing, context creation)
// runs unless the user has explicitly enabled GPU acceleration.
// This prevents GPU process instability on systems where WebGL2 is problematic
// (e.g., Apple Silicon Macs with certain macOS / Electron combinations).
const gpuAcceleration = useSettingsStore.getState().settings.gpuAcceleration ?? 'off';
debugLog(`[useXterm] WebGL check for ${terminalId}: gpuAcceleration=${gpuAcceleration}`);
if (gpuAcceleration !== 'off') {
import('../../lib/webgl-context-manager')
.then(({ webglContextManager }) => {
// Guard: terminal may have been disposed while the import was resolving
if (isDisposedRef.current) return;
webglManagerRef.current = webglContextManager;
webglContextManager.register(terminalId, xterm);
webglContextManager.acquire(terminalId);
debugLog(`[useXterm] WebGL acquired for ${terminalId}`);
})
.catch((error) => {
// WebGL is a progressive enhancement — terminal works fine without it
debugError(`[useXterm] WebGL initialization failed for ${terminalId}, falling back to canvas renderer:`, error);
});
}
// Platform detection for copy/paste shortcuts
// Use existing os-detection module instead of custom implementation
const isWindows = checkIsWindows();
@@ -552,6 +579,16 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
// Serialize buffer before disposing to preserve ANSI formatting
serializeBuffer();
// Release WebGL context before disposing addons and xterm (only if WebGL was loaded)
if (webglManagerRef.current) {
try {
webglManagerRef.current.unregister(terminalId);
} catch (error) {
debugError(`[useXterm] WebGL cleanup failed for ${terminalId}:`, error);
}
webglManagerRef.current = null;
}
// 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
@@ -184,6 +184,9 @@ class WebGLContextManager {
}
}
/** Type alias for the manager — used by consumers that import lazily via dynamic import() */
export type WebGLContextManagerType = WebGLContextManager;
// Export singleton instance
export const webglContextManager = WebGLContextManager.getInstance();
+5 -1
View File
@@ -67,7 +67,11 @@ 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
// Default to 'off' until WebGL stability is proven across all GPU drivers.
// Users can opt-in via Settings > Display > GPU Acceleration.
gpuAcceleration: 'off' as const
};
// ============================================
@@ -192,6 +192,14 @@
"chronological": "Chronological (oldest first)",
"reverseChronological": "Reverse-chronological (newest first)"
},
"gpuAcceleration": {
"label": "GPU Acceleration",
"description": "Use WebGL for terminal rendering (experimental, faster with many terminals)",
"auto": "Auto (use WebGL when supported)",
"on": "Always on",
"off": "Off (default)",
"helperText": "Changes apply to new terminals only"
},
"general": {
"otherAgentSettings": "Other Agent Settings",
"otherAgentSettingsDescription": "Additional agent configuration options",
@@ -192,6 +192,14 @@
"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 (expérimental, plus rapide avec plusieurs terminaux)",
"auto": "Auto (utiliser WebGL si supporté)",
"on": "Toujours activé",
"off": "Désactivé (par défaut)",
"helperText": "Les modifications s'appliquent uniquement aux nouveaux terminaux"
},
"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.)
+19 -3
View File
@@ -10,20 +10,36 @@ export const isDebugEnabled = (): boolean => {
return false;
};
function safeConsoleWarn(...args: unknown[]): void {
try {
console.warn(...args);
} catch {
// Ignore console stream failures (e.g. EIO) in debug logging paths.
}
}
function safeConsoleError(...args: unknown[]): void {
try {
console.error(...args);
} catch {
// Ignore console stream failures (e.g. EIO) in debug logging paths.
}
}
export const debugLog = (...args: unknown[]): void => {
if (isDebugEnabled()) {
console.warn(...args);
safeConsoleWarn(...args);
}
};
export const debugWarn = (...args: unknown[]): void => {
if (isDebugEnabled()) {
console.warn(...args);
safeConsoleWarn(...args);
}
};
export const debugError = (...args: unknown[]): void => {
if (isDebugEnabled()) {
console.error(...args);
safeConsoleError(...args);
}
};