fix(terminal): resolve text alignment issues on expand/minimize (#1650)
* fix(terminal): force PTY resize on mount and add auto-correction Root cause: Architecture mismatch between PTY lifecycle (persists in main process) and xterm lifecycle (destroyed/recreated on expand/minimize). When terminal remounts, PTY keeps old dimensions but new xterm assumes they match. Changes: - Force PTY resize on terminal mount/creation to ensure PTY matches xterm - Add auto-correction to checkDimensionMismatch() with cooldown to fix any detected mismatches automatically - Add validation and error handling to resizePty() in pty-manager - Revert console.log to debugLog for production readiness Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(terminal): add IPC acknowledgment, platform-specific timing, and correction monitoring - Change resizeTerminal from fire-and-forget to invoke/handle pattern so renderer gets confirmation of resize success/failure - Add platform detection via contextBridge (isWindows, isMacOS, isLinux, isUnix) - Use shorter grace periods on Unix (100ms) vs Windows (500ms) since Unix PTY resize is much faster than Windows ConPTY - Track auto-correction frequency and log warning if >5 corrections occur per minute, indicating potential deeper sync issues - Update all 4 resizeTerminal call sites to handle Promise and log failures Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(terminal): prevent stale closures in dimension handling - Read xterm dimensions from ref instead of React state to avoid stale closures - Fix onCreated callback using potentially outdated ptyDimensions - Fix expansion effect using old cols/rows after fit() - Fix post-PTY creation timeout using stale dimensions - Change warning threshold from > to >= for consistency Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(terminal): only update lastPtyDimensionsRef after successful resize Previously, lastPtyDimensionsRef was updated optimistically before the async resizeTerminal() call completed. If the resize failed, the ref would hold incorrect dimensions, causing future resize attempts with the same target dimensions to be incorrectly skipped. Now the ref is only updated after resizeTerminal() succeeds, and reverted to previous dimensions on failure. This ensures dimension mismatches aren't masked by failed resize operations. Fixed in 4 locations: - Auto-correction path - onResize callback - onCreated (PTY creation) - performFit (expansion) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: resolve CI failures from outdated develop branch - Fix Ruff formatting in parallel_orchestrator_reviewer.py and pydantic_models.py - Fix test_integration_phase4.py to register module in sys.modules before exec_module for Python 3.12+ dataclass compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: address PR review feedback - race condition and platform detection - Fix race condition in concurrent resize calls using sequence numbers to prevent stale dimension corruption when calls complete out-of-order - Use consistent platform detection via os-detection.ts module instead of window.platform for codebase consistency - Extract duplicated resizeTerminal promise handling to helper function resizePtyWithTracking() reducing code from 4 occurrences to 1 - Capture setTimeout ID for post-creation timeout to enable cleanup - Add proper cleanup in unmount effect for the timeout ref Addresses all blocking issues from Auto Claude PR Review: - NEW-001 [HIGH]: Race condition in concurrent resize calls - 47ffdb7e4a98 [HIGH]: Inconsistent platform detection - eabaccf549e4 [MEDIUM]: Duplicated resizeTerminal promise handling - 7821024a350c [LOW]: Uncleaned timeout in onCreated callback Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
This commit is contained in:
@@ -55,10 +55,11 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TERMINAL_RESIZE,
|
||||
(_, id: string, cols: number, rows: number) => {
|
||||
terminalManager.resize(id, cols, rows);
|
||||
async (_, id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> => {
|
||||
const success = terminalManager.resize(id, cols, rows);
|
||||
return { success, data: { success } };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ export function spawnPtyProcess(
|
||||
const shellArgs = isWindows() ? [] : ['-l'];
|
||||
|
||||
debugLog('[PtyManager] Spawning shell:', shell, shellArgs, '(preferred:', preferredTerminal || 'system', ', shellType:', shellType, ')');
|
||||
debugLog('[PtyManager] PTY dimensions requested - cols:', cols, 'rows:', rows, 'cwd:', cwd || os.homedir());
|
||||
|
||||
// Create a clean environment without DEBUG to prevent Claude Code from
|
||||
// enabling debug mode when the Electron app is run in development mode.
|
||||
@@ -355,10 +356,30 @@ export function writeToPty(terminal: TerminalProcess, data: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a PTY process
|
||||
* Resize a PTY process with validation and error handling.
|
||||
* @param terminal The terminal process to resize
|
||||
* @param cols New column count
|
||||
* @param rows New row count
|
||||
* @returns true if resize was successful, false otherwise
|
||||
*/
|
||||
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): void {
|
||||
terminal.pty.resize(cols, rows);
|
||||
export function resizePty(terminal: TerminalProcess, cols: number, rows: number): boolean {
|
||||
// Validate dimensions
|
||||
if (cols <= 0 || rows <= 0 || !Number.isFinite(cols) || !Number.isFinite(rows)) {
|
||||
debugError('[PtyManager] Invalid resize dimensions - terminal:', terminal.id, 'cols:', cols, 'rows:', rows);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const prevCols = terminal.pty.cols;
|
||||
const prevRows = terminal.pty.rows;
|
||||
debugLog('[PtyManager] Resizing PTY - terminal:', terminal.id, 'from:', prevCols, 'x', prevRows, 'to:', cols, 'x', rows);
|
||||
terminal.pty.resize(cols, rows);
|
||||
debugLog('[PtyManager] PTY resized - actual dimensions now:', terminal.pty.cols, 'x', terminal.pty.rows);
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugError('[PtyManager] Resize failed for terminal:', terminal.id, 'error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -137,12 +137,14 @@ export class TerminalManager {
|
||||
|
||||
/**
|
||||
* Resize a terminal
|
||||
* @returns true if resize was successful, false otherwise
|
||||
*/
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
resize(id: string, cols: number, rows: number): boolean {
|
||||
const terminal = this.terminals.get(id);
|
||||
if (terminal) {
|
||||
PtyManager.resizePty(terminal, cols, rows);
|
||||
if (!terminal) {
|
||||
return false;
|
||||
}
|
||||
return PtyManager.resizePty(terminal, cols, rows);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface TerminalAPI {
|
||||
createTerminal: (options: TerminalCreateOptions) => Promise<IPCResult>;
|
||||
destroyTerminal: (id: string) => Promise<IPCResult>;
|
||||
sendTerminalInput: (id: string, data: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => Promise<IPCResult<{ success: boolean }>>;
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
|
||||
generateTerminalName: (command: string, cwd?: string) => Promise<IPCResult<string>>;
|
||||
setTerminalTitle: (id: string, title: string) => void;
|
||||
@@ -137,8 +137,8 @@ export const createTerminalAPI = (): TerminalAPI => ({
|
||||
sendTerminalInput: (id: string, data: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INPUT, id, data),
|
||||
|
||||
resizeTerminal: (id: string, cols: number, rows: number): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows),
|
||||
resizeTerminal: (id: string, cols: number, rows: number): Promise<IPCResult<{ success: boolean }>> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.TERMINAL_RESIZE, id, cols, rows),
|
||||
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string): void =>
|
||||
ipcRenderer.send(IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, id, cwd),
|
||||
|
||||
@@ -9,3 +9,11 @@ contextBridge.exposeInMainWorld('electronAPI', electronAPI);
|
||||
|
||||
// Expose debug flag for debug logging
|
||||
contextBridge.exposeInMainWorld('DEBUG', process.env.DEBUG === 'true');
|
||||
|
||||
// Expose platform information for platform-specific behavior (e.g., PTY resize timing)
|
||||
contextBridge.exposeInMainWorld('platform', {
|
||||
isWindows: process.platform === 'win32',
|
||||
isMacOS: process.platform === 'darwin',
|
||||
isLinux: process.platform === 'linux',
|
||||
isUnix: process.platform !== 'win32',
|
||||
});
|
||||
|
||||
@@ -15,11 +15,30 @@ import { usePtyProcess } from './terminal/usePtyProcess';
|
||||
import { useTerminalEvents } from './terminal/useTerminalEvents';
|
||||
import { useAutoNaming } from './terminal/useAutoNaming';
|
||||
import { useTerminalFileDrop } from './terminal/useTerminalFileDrop';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
import { isWindows as checkIsWindows } from '../lib/os-detection';
|
||||
|
||||
// Minimum dimensions to prevent PTY creation with invalid sizes
|
||||
const MIN_COLS = 10;
|
||||
const MIN_ROWS = 3;
|
||||
|
||||
// Platform detection for platform-specific timing
|
||||
// Windows ConPTY is slower than Unix PTY, so we need longer grace periods
|
||||
const platformIsWindows = checkIsWindows();
|
||||
|
||||
// Threshold in milliseconds to allow for async PTY resize acknowledgment
|
||||
// Mismatches within this window after a resize are expected and not logged as warnings
|
||||
// Windows needs longer grace period due to slower ConPTY resize
|
||||
const DIMENSION_MISMATCH_GRACE_PERIOD_MS = platformIsWindows ? 500 : 100;
|
||||
|
||||
// Cooldown between auto-corrections to prevent rapid-fire corrections
|
||||
// Windows needs longer cooldown due to slower ConPTY operations
|
||||
const AUTO_CORRECTION_COOLDOWN_MS = platformIsWindows ? 1000 : 300;
|
||||
|
||||
// Auto-correction frequency monitoring
|
||||
const AUTO_CORRECTION_WARNING_THRESHOLD = 5; // Warn if > 5 corrections per minute
|
||||
const AUTO_CORRECTION_WINDOW_MS = 60000; // 1 minute window
|
||||
|
||||
/**
|
||||
* Handle interface exposed by Terminal component for external control.
|
||||
* Used by parent components (e.g., SortableTerminalWrapper) to trigger operations
|
||||
@@ -57,6 +76,23 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// 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);
|
||||
// Track when the last resize was sent to PTY for grace period logic
|
||||
// This prevents false positive mismatch warnings during async resize acknowledgment
|
||||
const lastResizeTimeRef = useRef<number>(0);
|
||||
// Track previous isExpanded state to detect actual expansion changes
|
||||
// This prevents forcing PTY resize on initial mount (only on actual state changes)
|
||||
const prevIsExpandedRef = useRef<boolean | undefined>(undefined);
|
||||
// Track when last auto-correction was performed to implement cooldown
|
||||
const lastAutoCorrectionTimeRef = useRef<number>(0);
|
||||
// Track auto-correction frequency to detect potential deeper issues
|
||||
// If corrections exceed threshold, it may indicate a persistent sync problem
|
||||
const autoCorrectionCountRef = useRef<number>(0);
|
||||
const autoCorrectionWindowStartRef = useRef<number>(Date.now());
|
||||
// Sequence number for resize operations to prevent race conditions
|
||||
// When concurrent resize calls complete out-of-order, only the latest result is applied
|
||||
const resizeSequenceRef = useRef<number>(0);
|
||||
// Track post-creation dimension check timeout for cleanup
|
||||
const postCreationTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Worktree dialog state
|
||||
const [showWorktreeDialog, setShowWorktreeDialog] = useState(false);
|
||||
@@ -108,18 +144,131 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// Track when xterm dimensions are ready for PTY creation
|
||||
const [readyDimensions, setReadyDimensions] = useState<{ cols: number; rows: number } | null>(null);
|
||||
|
||||
/**
|
||||
* Helper function to resize PTY with proper dimension tracking and race condition prevention.
|
||||
* Uses sequence numbers to ensure only the latest resize result updates the tracked dimensions.
|
||||
* This prevents stale dimension corruption when concurrent resize calls complete out-of-order.
|
||||
*
|
||||
* @param cols - Target column count
|
||||
* @param rows - Target row count
|
||||
* @param context - Context string for debug logging (e.g., "onResize", "performFit")
|
||||
*/
|
||||
const resizePtyWithTracking = useCallback((cols: number, rows: number, context: string) => {
|
||||
// Increment sequence number for this resize operation
|
||||
const sequence = ++resizeSequenceRef.current;
|
||||
lastResizeTimeRef.current = Date.now();
|
||||
|
||||
window.electronAPI.resizeTerminal(id, cols, rows).then((result) => {
|
||||
// Only update dimensions if this is still the latest resize operation
|
||||
// This prevents race conditions where an earlier failed call overwrites a later successful one
|
||||
if (sequence !== resizeSequenceRef.current) {
|
||||
debugLog(`[Terminal ${id}] ${context}: Ignoring stale resize result (sequence ${sequence} vs current ${resizeSequenceRef.current})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
lastPtyDimensionsRef.current = { cols, rows };
|
||||
} else {
|
||||
debugLog(`[Terminal ${id}] ${context} resize failed: ${result.error || 'unknown error'}`);
|
||||
}
|
||||
}).catch((error) => {
|
||||
// Only log if this is still the latest operation
|
||||
if (sequence === resizeSequenceRef.current) {
|
||||
debugLog(`[Terminal ${id}] ${context} resize error: ${error}`);
|
||||
}
|
||||
});
|
||||
}, [id]);
|
||||
|
||||
// Callback when xterm has measured valid dimensions
|
||||
const handleDimensionsReady = useCallback((cols: number, rows: number) => {
|
||||
// Only set dimensions if they're valid (above minimum thresholds)
|
||||
if (cols >= MIN_COLS && rows >= MIN_ROWS) {
|
||||
debugLog(`[Terminal ${id}] handleDimensionsReady: cols=${cols}, rows=${rows} - setting readyDimensions`);
|
||||
setReadyDimensions({ cols, rows });
|
||||
} else {
|
||||
debugLog(`[Terminal ${id}] handleDimensionsReady: dimensions below minimum: cols=${cols} (min=${MIN_COLS}), rows=${rows} (min=${MIN_ROWS})`);
|
||||
}
|
||||
}, []);
|
||||
}, [id]);
|
||||
|
||||
/**
|
||||
* Check for dimension mismatch between xterm and PTY.
|
||||
* Logs a warning if dimensions differ outside the grace period after a resize.
|
||||
* This helps diagnose text alignment issues that can occur when xterm and PTY
|
||||
* have different ideas about terminal dimensions.
|
||||
*
|
||||
* @param xtermCols - Current xterm column count
|
||||
* @param xtermRows - Current xterm row count
|
||||
* @param context - Optional context string for the log message (e.g., "after resize", "on fit")
|
||||
* @param autoCorrect - If true, automatically correct mismatches by resizing PTY
|
||||
*/
|
||||
const checkDimensionMismatch = useCallback((
|
||||
xtermCols: number,
|
||||
xtermRows: number,
|
||||
context?: string,
|
||||
autoCorrect: boolean = false
|
||||
) => {
|
||||
const ptyDims = lastPtyDimensionsRef.current;
|
||||
|
||||
// Skip check if PTY hasn't been created yet (no dimensions to compare)
|
||||
if (!ptyDims) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip check if we're within the grace period after a resize
|
||||
// This prevents false positives during async PTY resize acknowledgment
|
||||
const timeSinceLastResize = Date.now() - lastResizeTimeRef.current;
|
||||
if (timeSinceLastResize < DIMENSION_MISMATCH_GRACE_PERIOD_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for mismatch
|
||||
const colsMismatch = xtermCols !== ptyDims.cols;
|
||||
const rowsMismatch = xtermRows !== ptyDims.rows;
|
||||
|
||||
if (colsMismatch || rowsMismatch) {
|
||||
const contextStr = context ? ` (${context})` : '';
|
||||
debugLog(
|
||||
`[Terminal ${id}] DIMENSION MISMATCH DETECTED${contextStr}: ` +
|
||||
`xterm=(cols=${xtermCols}, rows=${xtermRows}) vs PTY=(cols=${ptyDims.cols}, rows=${ptyDims.rows}) - ` +
|
||||
`delta=(cols=${xtermCols - ptyDims.cols}, rows=${xtermRows - ptyDims.rows})`
|
||||
);
|
||||
|
||||
// Auto-correct if enabled, PTY is created, and cooldown has passed
|
||||
const timeSinceAutoCorrect = Date.now() - lastAutoCorrectionTimeRef.current;
|
||||
if (
|
||||
autoCorrect &&
|
||||
isCreatedRef.current &&
|
||||
timeSinceAutoCorrect >= AUTO_CORRECTION_COOLDOWN_MS &&
|
||||
xtermCols >= MIN_COLS &&
|
||||
xtermRows >= MIN_ROWS
|
||||
) {
|
||||
// Track auto-correction frequency for monitoring
|
||||
const now = Date.now();
|
||||
if (now - autoCorrectionWindowStartRef.current >= AUTO_CORRECTION_WINDOW_MS) {
|
||||
// Log warning if previous window had excessive corrections
|
||||
if (autoCorrectionCountRef.current >= AUTO_CORRECTION_WARNING_THRESHOLD) {
|
||||
debugLog(
|
||||
`[Terminal ${id}] AUTO-CORRECTION WARNING: ${autoCorrectionCountRef.current} corrections ` +
|
||||
`in last minute - this may indicate a persistent sync issue`
|
||||
);
|
||||
}
|
||||
// Reset the window
|
||||
autoCorrectionCountRef.current = 0;
|
||||
autoCorrectionWindowStartRef.current = now;
|
||||
}
|
||||
autoCorrectionCountRef.current++;
|
||||
|
||||
debugLog(`[Terminal ${id}] AUTO-CORRECTING (#${autoCorrectionCountRef.current}): resizing PTY to ${xtermCols}x${xtermRows}`);
|
||||
lastAutoCorrectionTimeRef.current = Date.now();
|
||||
resizePtyWithTracking(xtermCols, xtermRows, 'AUTO-CORRECTION');
|
||||
}
|
||||
}
|
||||
}, [id, resizePtyWithTracking]);
|
||||
|
||||
// Initialize xterm with command tracking
|
||||
const {
|
||||
terminalRef,
|
||||
xtermRef: _xtermRef,
|
||||
xtermRef,
|
||||
fit,
|
||||
write: _write, // Output now handled by useGlobalTerminalListeners
|
||||
writeln,
|
||||
@@ -150,9 +299,8 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tracked dimensions and send resize to PTY
|
||||
lastPtyDimensionsRef.current = { cols, rows };
|
||||
window.electronAPI.resizeTerminal(id, cols, rows);
|
||||
// Use helper to resize PTY with proper tracking and race condition prevention
|
||||
resizePtyWithTracking(cols, rows, 'onResize');
|
||||
},
|
||||
onDimensionsReady: handleDimensionsReady,
|
||||
});
|
||||
@@ -167,15 +315,14 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// This prevents creating PTY with default 80x24 when container is smaller
|
||||
const ptyDimensions = useMemo(() => {
|
||||
if (readyDimensions) {
|
||||
debugLog(`[Terminal ${id}] ptyDimensions memo: using readyDimensions cols=${readyDimensions.cols}, rows=${readyDimensions.rows}`);
|
||||
return readyDimensions;
|
||||
}
|
||||
// Fallback to current dimensions if they're valid
|
||||
if (cols >= MIN_COLS && rows >= MIN_ROWS) {
|
||||
return { cols, rows };
|
||||
}
|
||||
// Return null to prevent PTY creation until dimensions are ready
|
||||
// Wait for actual measurement via onDimensionsReady callback
|
||||
// Do NOT use current cols/rows as they may be initial defaults (80x24)
|
||||
debugLog(`[Terminal ${id}] ptyDimensions memo: readyDimensions is null, returning null (skipCreation will be true)`);
|
||||
return null;
|
||||
}, [readyDimensions, cols, rows]);
|
||||
}, [readyDimensions, id]);
|
||||
|
||||
// Create PTY process - only when we have valid dimensions
|
||||
const { prepareForRecreate, resetForRecreate } = usePtyProcess({
|
||||
@@ -190,10 +337,33 @@ 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 };
|
||||
// ALWAYS force PTY resize on creation/remount
|
||||
// This ensures PTY matches xterm even if PTY existed before remount (expand/minimize)
|
||||
// The root cause of text alignment issues is that when terminal remounts:
|
||||
// 1. PTY persists with old dimensions (e.g., 80x20)
|
||||
// 2. New xterm measures new container (e.g., 160x40)
|
||||
// 3. Without this force resize, PTY never gets updated
|
||||
// Read current dimensions from xterm ref to avoid stale closure values
|
||||
const currentCols = xtermRef.current?.cols;
|
||||
const currentRows = xtermRef.current?.rows;
|
||||
if (currentCols !== undefined && currentRows !== undefined && currentCols >= MIN_COLS && currentRows >= MIN_ROWS) {
|
||||
debugLog(`[Terminal ${id}] PTY created - forcing PTY resize to match xterm: cols=${currentCols}, rows=${currentRows}`);
|
||||
// Use helper to resize PTY with proper tracking and race condition prevention
|
||||
resizePtyWithTracking(currentCols, currentRows, 'PTY creation');
|
||||
|
||||
// Schedule initial dimension mismatch check after PTY creation
|
||||
// This helps detect if xterm dimensions drifted during PTY setup
|
||||
// Read fresh dimensions inside the timeout to avoid stale closure
|
||||
// Store timeout ID for cleanup on unmount
|
||||
postCreationTimeoutRef.current = setTimeout(() => {
|
||||
const freshCols = xtermRef.current?.cols;
|
||||
const freshRows = xtermRef.current?.rows;
|
||||
if (freshCols !== undefined && freshRows !== undefined) {
|
||||
checkDimensionMismatch(freshCols, freshRows, 'post-PTY creation');
|
||||
}
|
||||
}, DIMENSION_MISMATCH_GRACE_PERIOD_MS + 100);
|
||||
} else {
|
||||
debugLog(`[Terminal ${id}] PTY created - no valid dimensions available for tracking (cols=${currentCols}, rows=${currentRows})`);
|
||||
}
|
||||
// If there's a pending worktree config from a recreation attempt,
|
||||
// sync it to main process now that the terminal exists.
|
||||
@@ -217,6 +387,26 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
},
|
||||
});
|
||||
|
||||
// Monitor for dimension mismatches between xterm and PTY
|
||||
// This effect runs when xterm dimensions change and checks for mismatches
|
||||
// after the grace period to help diagnose text alignment issues
|
||||
// Auto-correction is enabled to automatically fix any detected mismatches
|
||||
useEffect(() => {
|
||||
// Only check if PTY has been created
|
||||
if (!isCreatedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Schedule a mismatch check after the grace period
|
||||
// This allows time for the PTY resize to be acknowledged
|
||||
// Enable auto-correct to automatically fix any detected mismatches
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkDimensionMismatch(cols, rows, 'periodic dimension sync check', true);
|
||||
}, DIMENSION_MISMATCH_GRACE_PERIOD_MS + 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [cols, rows, checkDimensionMismatch]);
|
||||
|
||||
// Handle terminal events (output is now handled globally via useGlobalTerminalListeners)
|
||||
useTerminalEvents({
|
||||
terminalId: id,
|
||||
@@ -239,6 +429,13 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
// Uses transitionend event listener and RAF-based retry logic instead of fixed timeout
|
||||
// for more reliable resizing after CSS transitions complete
|
||||
useEffect(() => {
|
||||
// Detect if this is an actual expansion state change vs initial mount
|
||||
// Only force PTY resize on actual state changes to avoid resizing with invalid dimensions on mount
|
||||
const isFirstMount = prevIsExpandedRef.current === undefined;
|
||||
const expansionStateChanged = !isFirstMount && prevIsExpandedRef.current !== isExpanded;
|
||||
debugLog(`[Terminal ${id}] Expansion effect: isExpanded=${isExpanded}, isFirstMount=${isFirstMount}, expansionStateChanged=${expansionStateChanged}, prevIsExpanded=${prevIsExpandedRef.current}`);
|
||||
prevIsExpandedRef.current = isExpanded;
|
||||
|
||||
// RAF fallback for test environments where requestAnimationFrame may not be defined
|
||||
const raf = typeof requestAnimationFrame !== 'undefined'
|
||||
? requestAnimationFrame
|
||||
@@ -273,9 +470,20 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
|
||||
// fit() returns boolean indicating success (true if container had valid dimensions)
|
||||
const success = fit();
|
||||
debugLog(`[Terminal ${id}] performFit: fit returned success=${success}, expansionStateChanged=${expansionStateChanged}, isCreatedRef=${isCreatedRef.current}`);
|
||||
|
||||
if (success) {
|
||||
fitSucceeded = true;
|
||||
// Force PTY resize only on actual expansion state changes (not initial mount)
|
||||
// This ensures PTY stays in sync even when xterm.onResize() doesn't fire
|
||||
// Read fresh dimensions from xterm ref after fit() to avoid stale closure values
|
||||
const freshCols = xtermRef.current?.cols;
|
||||
const freshRows = xtermRef.current?.rows;
|
||||
if (expansionStateChanged && isCreatedRef.current && freshCols !== undefined && freshRows !== undefined && freshCols >= MIN_COLS && freshRows >= MIN_ROWS) {
|
||||
debugLog(`[Terminal ${id}] performFit: Forcing PTY resize to cols=${freshCols}, rows=${freshRows}`);
|
||||
// Use helper to resize PTY with proper tracking and race condition prevention
|
||||
resizePtyWithTracking(freshCols, freshRows, 'performFit');
|
||||
}
|
||||
} else if (retryCount < MAX_RETRIES) {
|
||||
// Container not ready yet, retry after a short delay
|
||||
retryCount++;
|
||||
@@ -343,7 +551,7 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
container.parentElement?.removeEventListener('transitionend', handleTransitionEnd);
|
||||
}
|
||||
};
|
||||
}, [isExpanded, fit]);
|
||||
}, [isExpanded, fit, id, resizePtyWithTracking]);
|
||||
|
||||
// Trigger deferred Claude resume when terminal becomes active
|
||||
// This ensures Claude sessions are only resumed when the user actually views the terminal,
|
||||
@@ -390,6 +598,12 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(function Termi
|
||||
isMountedRef.current = false;
|
||||
cleanupAutoNaming();
|
||||
|
||||
// Clear post-creation dimension check timeout to prevent operations on unmounted component
|
||||
if (postCreationTimeoutRef.current !== null) {
|
||||
clearTimeout(postCreationTimeoutRef.current);
|
||||
postCreationTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isMountedRef.current) {
|
||||
dispose();
|
||||
|
||||
@@ -124,7 +124,7 @@ export function usePtyProcess({
|
||||
|
||||
// Normal skip (not during recreation) - just return
|
||||
if (skipCreation) {
|
||||
debugLog(`[usePtyProcess] Skipping PTY creation for terminal: ${terminalId} - dimensions not ready`);
|
||||
debugLog(`[usePtyProcess] Skipping PTY creation for terminal: ${terminalId} - dimensions not ready (skipCreation=true)`);
|
||||
return;
|
||||
}
|
||||
if (isCreatingRef.current || isCreatedRef.current) {
|
||||
@@ -140,7 +140,9 @@ export function usePtyProcess({
|
||||
const alreadyRunning = terminalState?.status === 'running' || terminalState?.status === 'claude-active';
|
||||
const isRestored = terminalState?.isRestored;
|
||||
|
||||
debugLog(`[usePtyProcess] Starting PTY creation for terminal: ${terminalId}, isRestored: ${isRestored}, status: ${terminalState?.status}, cols: ${cols}, rows: ${rows}`);
|
||||
debugLog(`[usePtyProcess] Starting PTY creation for terminal: ${terminalId}`);
|
||||
debugLog(`[usePtyProcess] Terminal ${terminalId} state: isRestored=${isRestored}, status=${terminalState?.status}`);
|
||||
debugLog(`[usePtyProcess] Terminal ${terminalId} dimensions for PTY: cols=${cols}, rows=${rows}`);
|
||||
|
||||
// When recreating (e.g., worktree switching), reset status from 'exited' to 'idle'
|
||||
// This allows proper recreation after deliberate terminal destruction
|
||||
|
||||
@@ -242,7 +242,7 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea
|
||||
// Call onDimensionsReady once when we have valid dimensions
|
||||
if (!dimensionsReadyCalledRef.current && cols > 0 && rows > 0) {
|
||||
dimensionsReadyCalledRef.current = true;
|
||||
debugLog(`[useXterm] Dimensions ready for terminal: ${terminalId}, cols: ${cols}, rows: ${rows}`);
|
||||
debugLog(`[useXterm] Dimensions ready for terminal: ${terminalId}, cols: ${cols}, rows: ${rows}, containerWidth: ${rect.width}, containerHeight: ${rect.height}`);
|
||||
onDimensionsReady?.(cols, rows);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -17,8 +17,9 @@ export const terminalMock = {
|
||||
console.warn('[Browser Mock] sendTerminalInput called');
|
||||
},
|
||||
|
||||
resizeTerminal: () => {
|
||||
resizeTerminal: async () => {
|
||||
console.warn('[Browser Mock] resizeTerminal called');
|
||||
return { success: true, data: { success: true } };
|
||||
},
|
||||
|
||||
invokeClaudeInTerminal: () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ export interface ElectronAPI {
|
||||
createTerminal: (options: TerminalCreateOptions) => Promise<IPCResult>;
|
||||
destroyTerminal: (id: string) => Promise<IPCResult>;
|
||||
sendTerminalInput: (id: string, data: string) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => void;
|
||||
resizeTerminal: (id: string, cols: number, rows: number) => Promise<IPCResult<{ success: boolean }>>;
|
||||
invokeClaudeInTerminal: (id: string, cwd?: string) => void;
|
||||
generateTerminalName: (command: string, cwd?: string) => Promise<IPCResult<string>>;
|
||||
setTerminalTitle: (id: string, title: string) => void;
|
||||
@@ -916,9 +916,18 @@ export interface ElectronAPI {
|
||||
queue: import('../../preload/api/queue-api').QueueAPI;
|
||||
}
|
||||
|
||||
/** Platform information exposed via contextBridge for platform-specific behavior */
|
||||
export interface PlatformInfo {
|
||||
isWindows: boolean;
|
||||
isMacOS: boolean;
|
||||
isLinux: boolean;
|
||||
isUnix: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
DEBUG: boolean;
|
||||
platform?: PlatformInfo;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user