diff --git a/src/main/services/ptyManager.ts b/src/main/services/ptyManager.ts index 6ec398a54..0fc375371 100644 --- a/src/main/services/ptyManager.ts +++ b/src/main/services/ptyManager.ts @@ -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', diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index 0d3632254..21e001ebe 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -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(); } diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 4e01f009d..fa44aaa5a 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -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; diff --git a/src/test/main/shellEnv.test.ts b/src/test/main/shellEnv.test.ts new file mode 100644 index 000000000..3fd2562a6 --- /dev/null +++ b/src/test/main/shellEnv.test.ts @@ -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(); + }); +});