diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index f1d659f2..bcb03a42 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -161,6 +161,9 @@ export function spawnPtyProcess( ...profileEnv, TERM: 'xterm-256color', COLORTERM: 'truecolor', + // Suppress zsh's partial line indicator (%) that appears when output + // doesn't end with a newline. This prevents rendering artifacts in the terminal. + PROMPT_EOL_MARK: '', }, }); diff --git a/apps/frontend/src/renderer/components/Terminal.tsx b/apps/frontend/src/renderer/components/Terminal.tsx index 7feefd61..21348d42 100644 --- a/apps/frontend/src/renderer/components/Terminal.tsx +++ b/apps/frontend/src/renderer/components/Terminal.tsx @@ -54,6 +54,9 @@ export const Terminal = forwardRef(function Termi // This fixes a race condition where IPC calls to set worktree config happen before // the terminal exists in main process, causing the config to not be persisted const pendingWorktreeConfigRef = useRef(null); + // Track last sent PTY dimensions to prevent redundant resize calls + // This ensures terminal.resize() stays in sync with PTY dimensions + const lastPtyDimensionsRef = useRef<{ cols: number; rows: number } | null>(null); // Worktree dialog state const [showWorktreeDialog, setShowWorktreeDialog] = useState(false); @@ -128,9 +131,28 @@ export const Terminal = forwardRef(function Termi terminalId: id, onCommandEnter: handleCommandEnter, onResize: (cols, rows) => { - if (isCreatedRef.current) { - window.electronAPI.resizeTerminal(id, cols, rows); + // PTY dimension sync validation: + // 1. Only resize if PTY is created + // 2. Validate dimensions are within acceptable range + // 3. Skip if dimensions haven't changed (prevents redundant IPC calls) + if (!isCreatedRef.current) { + return; } + + // Validate dimensions are within acceptable range + if (cols < MIN_COLS || rows < MIN_ROWS) { + return; + } + + // Skip redundant resize calls if dimensions haven't changed + const lastDims = lastPtyDimensionsRef.current; + if (lastDims && lastDims.cols === cols && lastDims.rows === rows) { + return; + } + + // Update tracked dimensions and send resize to PTY + lastPtyDimensionsRef.current = { cols, rows }; + window.electronAPI.resizeTerminal(id, cols, rows); }, onDimensionsReady: handleDimensionsReady, }); @@ -168,6 +190,11 @@ export const Terminal = forwardRef(function Termi isRecreatingRef, onCreated: () => { isCreatedRef.current = true; + // Initialize PTY dimension tracking with creation dimensions + // This ensures the first resize check has a baseline to compare against + if (ptyDimensions) { + lastPtyDimensionsRef.current = { cols: ptyDimensions.cols, rows: ptyDimensions.rows }; + } // If there's a pending worktree config from a recreation attempt, // sync it to main process now that the terminal exists. // This fixes the race condition where IPC calls happen before terminal creation. @@ -449,6 +476,10 @@ Please confirm you're ready by saying: I'm ready to work on ${selectedTask.title isCreatedRef.current = false; } + // Reset PTY dimension tracking for new terminal + // This ensures the new PTY will receive initial dimensions correctly + lastPtyDimensionsRef.current = null; + // Reset refs to allow recreation - effect will now trigger with new cwd resetForRecreate(); }, [id, setWorktreeConfig, updateTerminal, prepareForRecreate, resetForRecreate]); diff --git a/apps/frontend/src/renderer/components/TerminalGrid.tsx b/apps/frontend/src/renderer/components/TerminalGrid.tsx index bc2af805..fad2c816 100644 --- a/apps/frontend/src/renderer/components/TerminalGrid.tsx +++ b/apps/frontend/src/renderer/components/TerminalGrid.tsx @@ -157,16 +157,27 @@ export function TerminalGrid({ projectPath, onNewTaskClick, isActive = false }: }); // Add each successfully restored session to the renderer's terminal store + // Use staggered initialization to prevent race conditions when multiple terminals + // try to initialize and measure dimensions simultaneously + const TERMINAL_INIT_STAGGER_MS = 75; // Small delay between each terminal + for (const sessionResult of result.data.sessions) { if (sessionResult.success) { const fullSession = sortedSessions.find(s => s.id === sessionResult.id); if (fullSession) { console.warn(`[TerminalGrid] Adding restored terminal to store: ${fullSession.id}`); addRestoredTerminal(fullSession); + // Stagger terminal initialization to prevent race conditions + await new Promise(resolve => setTimeout(resolve, TERMINAL_INIT_STAGGER_MS)); } } } + // Trigger terminal refit after grid layout stabilizes to ensure correct dimensions + setTimeout(() => { + window.dispatchEvent(new CustomEvent('terminal-refit-all')); + }, TERMINAL_DOM_UPDATE_DELAY_MS); + // Refresh session dates to update counts const datesResult = await window.electronAPI.getTerminalSessionDates(projectPath); if (datesResult.success && datesResult.data) { diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index a9996b3e..e408a452 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -382,7 +382,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea } } } - }, 100); // 100ms debounce to prevent layout thrashing + }, 200); // 200ms debounce for xterm.js resize stability (recommended minimum) // Observe the terminalRef directly (not parent) for accurate resize detection const container = terminalRef.current; diff --git a/apps/frontend/src/renderer/lib/webgl-context-manager.ts b/apps/frontend/src/renderer/lib/webgl-context-manager.ts index b5ea47b5..0ddf1866 100644 --- a/apps/frontend/src/renderer/lib/webgl-context-manager.ts +++ b/apps/frontend/src/renderer/lib/webgl-context-manager.ts @@ -9,7 +9,7 @@ import { WebglAddon } from '@xterm/addon-webgl'; import type { Terminal } from '@xterm/xterm'; -import { supportsWebGL2, getMaxWebGLContexts } from './webgl-utils'; +import { supportsWebGL2, getMaxWebGLContexts, isSafari } from './webgl-utils'; class WebGLContextManager { private static instance: WebGLContextManager; @@ -21,10 +21,18 @@ class WebGLContextManager { private constructor() { // Check WebGL support once at startup - this.isSupported = supportsWebGL2(); + // Skip WebGL on Safari due to known rendering issues with xterm.js WebGL addon + // Safari will use Canvas renderer fallback for more stable rendering + const safariDetected = isSafari(); + this.isSupported = !safariDetected && supportsWebGL2(); // Use conservative max based on browser detection this.MAX_CONTEXTS = Math.min(getMaxWebGLContexts(), 8); + if (safariDetected) { + console.warn( + '[WebGLContextManager] Safari detected - WebGL disabled, using Canvas renderer fallback' + ); + } console.warn( `[WebGLContextManager] Initialized - Supported: ${this.isSupported}, Max contexts: ${this.MAX_CONTEXTS}` ); diff --git a/apps/frontend/src/renderer/lib/webgl-utils.ts b/apps/frontend/src/renderer/lib/webgl-utils.ts index 6c5228ea..529e1b50 100644 --- a/apps/frontend/src/renderer/lib/webgl-utils.ts +++ b/apps/frontend/src/renderer/lib/webgl-utils.ts @@ -31,6 +31,25 @@ export function supportsWebGL(): boolean { } } +/** + * Check if the current browser is Safari + * Note: Chrome's user agent also contains "Safari", so we need to exclude it + */ +export function isSafari(): boolean { + try { + const userAgent = navigator.userAgent.toLowerCase(); + // Safari includes "safari" but not "chrome" or "chromium" + // Chrome/Chromium include both "safari" and "chrome"/"chromium" + return ( + userAgent.includes('safari') && + !userAgent.includes('chrome') && + !userAgent.includes('chromium') + ); + } catch { + return false; + } +} + /** * Get the maximum number of WebGL contexts supported by the browser * This is a conservative estimate - browsers typically support 8-16 @@ -49,7 +68,7 @@ export function getMaxWebGLContexts(): number { } else if (userAgent.includes('firefox')) { // Firefox typically supports 32 maxContexts = 32; - } else if (userAgent.includes('safari')) { + } else if (isSafari()) { // Safari is more conservative maxContexts = 8; }