Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/main/services/ptyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const AGENT_ENV_VARS = [
'AZURE_OPENAI_API_ENDPOINT',
'AZURE_OPENAI_API_KEY',
'AZURE_OPENAI_KEY',
'CLAUDE_CONFIG_DIR',
'CODEBUFF_API_KEY',
'COPILOT_CLI_TOKEN',
'CURSOR_API_KEY',
Expand Down
14 changes: 14 additions & 0 deletions src/main/utils/shellEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,5 +287,19 @@ export function initializeShellEnvironment(): void {
console.log('[shellEnv] SSH_AUTH_SOCK not detected');
}

// Detect CLAUDE_CONFIG_DIR from login shell when not already in process.env.
// Electron GUI apps on macOS don't inherit the user's shell profile, so the
// var may be missing even if the user has it in ~/.zshrc / ~/.bash_profile.
const existingClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim();
if (!existingClaudeConfigDir) {
const claudeConfigDir = getShellEnvVar('CLAUDE_CONFIG_DIR');
if (claudeConfigDir) {
process.env.CLAUDE_CONFIG_DIR = claudeConfigDir;
console.log('[shellEnv] Detected CLAUDE_CONFIG_DIR');
}
} else {
process.env.CLAUDE_CONFIG_DIR = existingClaudeConfigDir;
}

initializeLocaleEnvironment();
}
23 changes: 23 additions & 0 deletions src/renderer/terminal/TerminalSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,29 @@ export class TerminalSessionManager {
element.style.height = '100%';
}

// Fit the terminal immediately after opening so that connectPty() (which
// runs asynchronously, waiting on fetchSnapshot IPC) receives the actual
// terminal dimensions rather than the fallback initial size. Without this,
// the PTY is spawned at the wrong size and Claude Code's React Ink TUI
// receives a SIGWINCH right after startup, which desynchronises its
// statusLine line-count counter and breaks statusLine rendering.
try {
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (
width >= MIN_RENDERABLE_TERMINAL_WIDTH_PX &&
height >= MIN_RENDERABLE_TERMINAL_HEIGHT_PX
) {
this.fitAddon.fit();
this.options.initialSize = {
cols: Math.max(MIN_TERMINAL_COLS, this.terminal.cols),
rows: Math.max(MIN_TERMINAL_ROWS, this.terminal.rows),
};
}
} catch {
// If fit fails (e.g. zero-size container), leave initialSize unchanged.
}

const selectionDisposable = this.terminal.onSelectionChange(() => {
if (!this.autoCopyOnSelection || this.disposed) return;
if (!this.terminal.hasSelection()) return;
Expand Down
146 changes: 146 additions & 0 deletions src/test/main/shellEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const execSyncMock = vi.fn();

vi.mock('child_process', () => ({
execSync: (...args: any[]) => execSyncMock(...args),
}));

// Prevent socket-detection calls in detectSshAuthSock from touching real fs
vi.mock('fs', () => {
const mock = {
statSync: vi.fn().mockImplementation(() => {
throw new Error('ENOENT');
}),
readdirSync: vi.fn().mockReturnValue([]),
};
return { ...mock, default: mock };
});

describe('getShellEnvVar', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
});

it('returns the trimmed value from the shell', async () => {
execSyncMock.mockReturnValue('/custom/claude/config\n');
const { getShellEnvVar } = await import('../../main/utils/shellEnv');
expect(getShellEnvVar('CLAUDE_CONFIG_DIR')).toBe('/custom/claude/config');
});

it('returns undefined when the shell outputs nothing', async () => {
execSyncMock.mockReturnValue('');
const { getShellEnvVar } = await import('../../main/utils/shellEnv');
expect(getShellEnvVar('CLAUDE_CONFIG_DIR')).toBeUndefined();
});

it('returns undefined without calling execSync for invalid var names', async () => {
const { getShellEnvVar } = await import('../../main/utils/shellEnv');
expect(getShellEnvVar('invalid-name')).toBeUndefined();
expect(execSyncMock).not.toHaveBeenCalled();
});

it('returns undefined when execSync throws', async () => {
execSyncMock.mockImplementation(() => {
throw new Error('shell unavailable');
});
const { getShellEnvVar } = await import('../../main/utils/shellEnv');
expect(getShellEnvVar('CLAUDE_CONFIG_DIR')).toBeUndefined();
});
});

describe('initializeShellEnvironment — CLAUDE_CONFIG_DIR', () => {
let savedClaudeConfigDir: string | undefined;
let savedLang: string | undefined;

beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();

savedClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
savedLang = process.env.LANG;

delete process.env.CLAUDE_CONFIG_DIR;

// Set a UTF-8 locale so initializeLocaleEnvironment() exits early and
// doesn't issue its own execSync calls, keeping the mock simple.
process.env.LANG = 'en_US.UTF-8';

// Default: execSync returns empty (no SSH_AUTH_SOCK from launchctl, no
// CLAUDE_CONFIG_DIR from shell, no locale vars).
execSyncMock.mockReturnValue('');
});

afterEach(() => {
if (savedClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = savedClaudeConfigDir;
}

if (savedLang === undefined) {
delete process.env.LANG;
} else {
process.env.LANG = savedLang;
}
});

it('sets CLAUDE_CONFIG_DIR from the login shell when absent from process.env', async () => {
execSyncMock.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('CLAUDE_CONFIG_DIR')) {
return '/shell/custom/claude\n';
}
return '';
});

const { initializeShellEnvironment } = await import('../../main/utils/shellEnv');
initializeShellEnvironment();

expect(process.env.CLAUDE_CONFIG_DIR).toBe('/shell/custom/claude');
});

it('does not override CLAUDE_CONFIG_DIR already present in process.env', async () => {
process.env.CLAUDE_CONFIG_DIR = '/existing/path';
execSyncMock.mockReturnValue('/shell/custom/claude\n');

const { initializeShellEnvironment } = await import('../../main/utils/shellEnv');
initializeShellEnvironment();

expect(process.env.CLAUDE_CONFIG_DIR).toBe('/existing/path');
});

it('treats whitespace-only CLAUDE_CONFIG_DIR as unset and falls back to shell', async () => {
process.env.CLAUDE_CONFIG_DIR = ' ';
execSyncMock.mockImplementation((cmd: string) => {
if (typeof cmd === 'string' && cmd.includes('CLAUDE_CONFIG_DIR')) {
return '/shell/custom/claude\n';
}
return '';
});

const { initializeShellEnvironment } = await import('../../main/utils/shellEnv');
initializeShellEnvironment();

expect(process.env.CLAUDE_CONFIG_DIR).toBe('/shell/custom/claude');
});

it('trims a padded CLAUDE_CONFIG_DIR already present in process.env', async () => {
process.env.CLAUDE_CONFIG_DIR = ' /existing/path ';
execSyncMock.mockReturnValue('/shell/custom/claude\n');

const { initializeShellEnvironment } = await import('../../main/utils/shellEnv');
initializeShellEnvironment();

expect(process.env.CLAUDE_CONFIG_DIR).toBe('/existing/path');
});

it('leaves CLAUDE_CONFIG_DIR unset when the shell returns nothing', async () => {
execSyncMock.mockReturnValue('');

const { initializeShellEnvironment } = await import('../../main/utils/shellEnv');
initializeShellEnvironment();

expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined();
});
});
Loading