Compare commits

...

6 Commits

Author SHA1 Message Date
AndyMik90 04618ccb08 fix: move debug log after delete to fix off-by-one count in cleanup timer
The cleanup timer debug log was computing pendingDelete.size - 1 before
the actual delete call, logging an incorrect remaining count. Moved both
delete calls before the debug log so it reports the accurate size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:33:42 +01:00
AndyMik90 e17137e937 fix: address PR #1831 review findings for webgl-context-management
- Remove unused fireEvent import from DisplaySettings test
- Add i18n helper text below GPU acceleration dropdown (en/fr)
- Replace positional selectCallbacks array with Map keyed by Select id
- Move debugLog after delete in terminal-session-store cleanup timer
- Remove redundant hasExited guard in pty-manager synchronous write path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:32:44 +01:00
AndyMik90 9e276c4757 fix(logging): enhance error handling in app-logger and terminal processes
- Introduced safe logging functions to prevent crashes from console write failures and unhandled errors.
- Updated error logging setup to use safeLogUnhandled for uncaught exceptions and unhandled rejections.
- Added guards in terminal process management to avoid operations on exited PTYs, preventing potential crashes.
- Improved WebGL context management in terminal rendering to ensure stability during GPU acceleration.

This commit aims to enhance the robustness of the logging and terminal handling mechanisms, addressing potential crash scenarios and improving overall application stability.
2026-02-17 15:32:44 +01:00
AndyMik90 d745aee48d fix(terminal): resolve pendingDelete race and save contention causing crashes
Two bugs in terminal session persistence caused crashes when terminals
were destroyed and recreated with the same ID:

1. pendingDelete blocked legitimate terminal recreation: When a terminal
   exits and is recreated (worktree switch, shell restart), the 5-second
   pendingDelete window silently blocked all session saves for the new
   terminal, leaving it invisible to the session store. Added
   clearPendingDelete() to remove the guard when createTerminal() is
   called with a reused ID.

2. Sync save() and async saveAsync() raced on the same temp file: Both
   methods wrote to terminals.json.tmp without coordination, causing
   ENOENT errors when one renamed a file the other had already moved.
   save() now checks writeInProgress and defers to the async writer
   when a write is in-flight.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:32:44 +01:00
AndyMik90 5495e6564e fix(terminal): wrap WebGL calls in try-catch and add crash diagnostics
The WebGL register/acquire/unregister calls in useXterm were not wrapped
in try-catch, meaning any failure during WebGL context acquisition
(e.g., LRU eviction edge case, GPU memory pressure) would propagate as
an uncaught exception and crash the renderer process.

Also adds debug logging to terminal-session-store's pendingDelete
mechanism to help diagnose a reported crash when spawning terminals
after extended use with 5+ concurrent instances. The "Skipping save
for deleted session" warning now includes the full pendingDelete state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:32:44 +01:00
AndyMik90 f399cd8ce2 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 <noreply@anthropic.com>
2026-02-17 15:32:43 +01:00
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
@@ -1220,10 +1208,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
@@ -1313,7 +1298,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:', {
@@ -1409,7 +1396,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
@@ -1433,10 +1422,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);
}
};