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:
@@ -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');
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user