From fc5ba4cf11b50eb94e94a31b96b2e328560d3e14 Mon Sep 17 00:00:00 2001 From: ubuntudroid Date: Tue, 31 Mar 2026 10:15:21 +0200 Subject: [PATCH 1/4] fix(claude): pass CLAUDE_CONFIG_DIR to subprocess and fix initial PTY size - Add CLAUDE_CONFIG_DIR to AGENT_ENV_VARS so custom config dirs (e.g. with claude-hud installed) are forwarded to the Claude Code subprocess - Detect CLAUDE_CONFIG_DIR from the login shell in initializeShellEnvironment for macOS GUI launches that don't inherit shell profile env vars - Fit the xterm terminal synchronously after terminal.open() in TerminalSessionManager.attach() and update options.initialSize before connectPty() runs; prevents the startup SIGWINCH that desynchronised React Ink's statusLine line-count counter in Claude Code's TUI Co-Authored-By: Claude Sonnet 4.6 --- src/main/services/ptyManager.ts | 1 + src/main/utils/shellEnv.ts | 11 +++++++++++ src/renderer/terminal/TerminalSessionManager.ts | 13 +++++++++++++ 3 files changed, 25 insertions(+) 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..3eb2dab1d 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -287,5 +287,16 @@ 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. + if (!process.env.CLAUDE_CONFIG_DIR) { + const claudeConfigDir = getShellEnvVar('CLAUDE_CONFIG_DIR'); + if (claudeConfigDir) { + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; + console.log('[shellEnv] Detected CLAUDE_CONFIG_DIR:', claudeConfigDir); + } + } + initializeLocaleEnvironment(); } diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 4e01f009d..8fd9cce13 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -487,6 +487,19 @@ 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 { + this.fitAddon.fit(); + this.options.initialSize = { cols: this.terminal.cols, 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; From e46aa594f7904199cd5064e21855dfa7a4f2e134 Mon Sep 17 00:00:00 2001 From: ubuntudroid Date: Tue, 31 Mar 2026 10:32:17 +0200 Subject: [PATCH 2/4] test(shellEnv): add tests for CLAUDE_CONFIG_DIR detection Cover getShellEnvVar (valid name, empty output, invalid name, execSync error) and initializeShellEnvironment (sets var from shell when absent, does not override existing value, leaves it unset when shell returns nothing). Co-Authored-By: Claude Sonnet 4.6 --- src/test/main/shellEnv.test.ts | 121 +++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/test/main/shellEnv.test.ts diff --git a/src/test/main/shellEnv.test.ts b/src/test/main/shellEnv.test.ts new file mode 100644 index 000000000..76d0dcf59 --- /dev/null +++ b/src/test/main/shellEnv.test.ts @@ -0,0 +1,121 @@ +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('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(); + }); +}); From 02371c22013835c8b38cac627b71a5f6a6baee7a Mon Sep 17 00:00:00 2001 From: ubuntudroid Date: Tue, 31 Mar 2026 10:34:00 +0200 Subject: [PATCH 3/4] fix: address coderabbit review comments - Drop raw path from CLAUDE_CONFIG_DIR log to avoid leaking user home directory into shared logs - Guard fitAddon.fit() and initialSize update against hidden/zero-size containers using existing MIN_RENDERABLE_TERMINAL_{WIDTH,HEIGHT}_PX thresholds; add Math.max guards on cols/rows for extra safety Co-Authored-By: Claude Sonnet 4.6 --- src/main/utils/shellEnv.ts | 2 +- src/renderer/terminal/TerminalSessionManager.ts | 14 ++++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index 3eb2dab1d..7db264014 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -294,7 +294,7 @@ export function initializeShellEnvironment(): void { const claudeConfigDir = getShellEnvVar('CLAUDE_CONFIG_DIR'); if (claudeConfigDir) { process.env.CLAUDE_CONFIG_DIR = claudeConfigDir; - console.log('[shellEnv] Detected CLAUDE_CONFIG_DIR:', claudeConfigDir); + console.log('[shellEnv] Detected CLAUDE_CONFIG_DIR'); } } diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index 8fd9cce13..fa44aaa5a 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -494,8 +494,18 @@ export class TerminalSessionManager { // receives a SIGWINCH right after startup, which desynchronises its // statusLine line-count counter and breaks statusLine rendering. try { - this.fitAddon.fit(); - this.options.initialSize = { cols: this.terminal.cols, rows: this.terminal.rows }; + 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. } From 87e805383f9d0dce5bd16ad572b176b28a052c18 Mon Sep 17 00:00:00 2001 From: ubuntudroid Date: Tue, 31 Mar 2026 10:50:44 +0200 Subject: [PATCH 4/4] fix(shellEnv): treat whitespace-only CLAUDE_CONFIG_DIR as unset Trim process.env.CLAUDE_CONFIG_DIR before the truthiness check so a value of e.g. ' ' falls through to shell detection rather than propagating an invalid path. Also trim a non-empty padded value in place. Adds two additional test cases to cover both scenarios. --- src/main/utils/shellEnv.ts | 5 ++++- src/test/main/shellEnv.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts index 7db264014..21e001ebe 100644 --- a/src/main/utils/shellEnv.ts +++ b/src/main/utils/shellEnv.ts @@ -290,12 +290,15 @@ export function initializeShellEnvironment(): void { // 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. - if (!process.env.CLAUDE_CONFIG_DIR) { + 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/test/main/shellEnv.test.ts b/src/test/main/shellEnv.test.ts index 76d0dcf59..3fd2562a6 100644 --- a/src/test/main/shellEnv.test.ts +++ b/src/test/main/shellEnv.test.ts @@ -110,6 +110,31 @@ describe('initializeShellEnvironment — CLAUDE_CONFIG_DIR', () => { 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('');