Fix Terminal UI Rendering Issues (#1514)

* auto-claude: subtask-1-1 - Add PROMPT_EOL_MARK='' to PTY spawn environment

Add PROMPT_EOL_MARK='' environment variable to suppress zsh's partial line
indicator (%) that appears when command output doesn't end with a newline.
This prevents rendering artifacts in the terminal UI.

* auto-claude: subtask-2-1 - Increase resize debounce from 100ms to 200ms

* auto-claude: subtask-2-2 - Add PTY dimension sync validation in Terminal.tsx

- Add lastPtyDimensionsRef to track last sent PTY dimensions
- Validate dimensions are within acceptable range before sending resize
- Skip redundant resize calls when dimensions haven't changed
- Reset dimension tracking during worktree switching
- Initialize dimension tracking when PTY is created

This prevents race conditions from rapid resize events and ensures
terminal.resize() stays in sync with PTY dimensions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-3-1 - Add Safari browser detection function to webgl-utils

* auto-claude: subtask-3-2 - Update webgl-context-manager.ts to skip WebGL on Safari

- Import isSafari from webgl-utils
- Check for Safari browser in constructor
- Disable WebGL and force Canvas renderer fallback on Safari
- Add informative log message when Safari is detected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* auto-claude: subtask-4-1 - Add staggered terminal restoration in TerminalGrid

Add 75ms delay between adding each restored terminal to prevent race conditions
when multiple terminals initialize and measure dimensions simultaneously during
session restoration from history.

* auto-claude: subtask-4-2 - Add terminal refit trigger after grid layout stabi

Add terminal-refit-all event dispatch after session restoration loop
completes to force dimension recalculation once all terminals are added.
This ensures correct terminal dimensions after grid layout stabilizes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: consolidate Safari detection logic in webgl-utils

Replace inline Safari user-agent check with isSafari() function to
eliminate duplicate detection logic. This removes maintenance risk
where Safari detection was inconsistent - getMaxWebGLContexts() was
using simple userAgent.includes('safari') while isSafari() correctly
excludes Chrome/Chromium browsers.

The bug was latent due to Chrome being checked first in the if-else
chain, but this consolidation prevents future issues if conditions
are reordered or modified.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-01-26 09:53:09 +01:00
committed by GitHub
parent 9f6c0026bc
commit 8d8306b8eb
6 changed files with 78 additions and 6 deletions
@@ -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: '',
},
});
@@ -54,6 +54,9 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(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<TerminalWorktreeConfig | null>(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<TerminalHandle, TerminalProps>(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<TerminalHandle, TerminalProps>(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]);
@@ -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) {
@@ -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;
@@ -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}`
);
+20 -1
View File
@@ -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;
}