diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 5941a37c5ff..de38ec13b17 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -93,20 +93,29 @@ export function registerPtyHandlers(mainWindow: BrowserWindow, runtime?: OrcaRun ? process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\' : process.env.HOME || '/' - const ptyProcess = pty.spawn(shellPath, shellArgs, { - name: 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd: args.cwd || defaultCwd, - env: { - ...process.env, - ...args.env, - TERM: 'xterm-256color', - COLORTERM: 'truecolor', - TERM_PROGRAM: 'Orca', - FORCE_HYPERLINK: '1' - } as Record - }) + let ptyProcess: pty.IPty + try { + ptyProcess = pty.spawn(shellPath, shellArgs, { + name: 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd: args.cwd || defaultCwd, + env: { + ...process.env, + ...args.env, + TERM: 'xterm-256color', + COLORTERM: 'truecolor', + TERM_PROGRAM: 'Orca', + FORCE_HYPERLINK: '1' + } as Record + }) + } catch (err) { + // Why: node-pty.spawn can throw if the shell binary doesn't exist, + // permissions are denied, or the cwd is invalid. Surface the error + // to the renderer so it can show a diagnostic instead of a blank pane. + const message = err instanceof Error ? err.message : String(err) + throw new Error(`Failed to spawn shell "${shellPath}": ${message}`) + } ptyProcesses.set(id, ptyProcess) ptyShellName.set(id, basename(shellPath)) diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx new file mode 100644 index 00000000000..62446635e7d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -0,0 +1,58 @@ +export function TerminalErrorToast({ + error, + onDismiss +}: { + error: string + onDismiss: () => void +}): React.JSX.Element { + return ( +
+
+ + {error} + {'\n'} + If this persists, please{' '} + + file an issue + + . + + +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index fc0fdba5187..8d5eb37af36 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -15,6 +15,7 @@ import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-seri import { createExpandCollapseActions } from './expand-collapse' import { useTerminalKeyboardShortcuts, useTerminalFontZoom } from './keyboard-handlers' import CloseTerminalDialog from './CloseTerminalDialog' +import { TerminalErrorToast } from './TerminalErrorToast' import TerminalContextMenu from './TerminalContextMenu' import { useSystemPrefersDark } from './use-system-prefers-dark' import { useTerminalPaneGlobalEffects } from './use-terminal-pane-global-effects' @@ -60,6 +61,10 @@ export default function TerminalPane({ const [expandedPaneId, setExpandedPaneId] = useState(null) const [searchOpen, setSearchOpen] = useState(false) const [closeConfirmPaneId, setCloseConfirmPaneId] = useState(null) + const [terminalError, setTerminalError] = useState(null) + const onPtyErrorRef = useRef((_paneId: number, message: string) => { + setTerminalError((prev) => (prev ? `${prev}\n${message}` : message)) + }) const setTabPaneExpanded = useAppStore((store) => store.setTabPaneExpanded) const setTabCanExpandPane = useAppStore((store) => store.setTabCanExpandPane) @@ -188,6 +193,7 @@ export default function TerminalPane({ pendingWritesRef, isActiveRef, onPtyExitRef, + onPtyErrorRef, clearTabPtyId, updateTabTitle, updateTabPtyId, @@ -391,6 +397,9 @@ export default function TerminalPane({ transport.sendInput(shellEscapePath(filePath)) }} /> + {terminalError && isActive && ( + setTerminalError(null)} /> + )} {activePane?.container && createPortal( { // --------------------------------------------------------------------------- // buildFontFamily // --------------------------------------------------------------------------- +const FULL_FALLBACK = + '"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", monospace' + describe('buildFontFamily', () => { - it('puts custom font first with SF Mono, Menlo, monospace fallbacks', () => { + it('puts custom font first with full cross-platform fallback chain', () => { const result = buildFontFamily('JetBrains Mono') - expect(result).toBe('"JetBrains Mono", "SF Mono", Menlo, monospace') + expect(result).toBe(`"JetBrains Mono", ${FULL_FALLBACK}`) }) it('does not duplicate SF Mono when it is the input', () => { const result = buildFontFamily('SF Mono') - expect(result).toBe('"SF Mono", Menlo, monospace') + expect(result).toBe( + '"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", monospace' + ) }) - it('returns SF Mono, Menlo, monospace for empty string', () => { + it('returns full fallback chain for empty string', () => { const result = buildFontFamily('') - expect(result).toBe('"SF Mono", Menlo, monospace') + expect(result).toBe(FULL_FALLBACK) }) it('treats whitespace-only string same as empty', () => { const result = buildFontFamily(' ') - expect(result).toBe('"SF Mono", Menlo, monospace') + expect(result).toBe(FULL_FALLBACK) }) it('does not duplicate when font name contains "sf mono" (case-insensitive)', () => { const result = buildFontFamily('My SF Mono Custom') - expect(result).toBe('"My SF Mono Custom", Menlo, monospace') + expect(result).toBe( + '"My SF Mono Custom", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", monospace' + ) + }) + + it('does not duplicate Consolas when it is the input', () => { + const result = buildFontFamily('Consolas') + expect(result).toBe( + '"Consolas", "SF Mono", "Menlo", "Monaco", "Cascadia Mono", "DejaVu Sans Mono", "Liberation Mono", monospace' + ) }) }) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index a19090fea2a..e0d9d3db01d 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -15,14 +15,34 @@ export function paneLeafId(paneId: number): string { return `pane:${paneId}` } +// Cross-platform monospace fallback chain ensures the terminal always has a +// usable font regardless of OS. macOS-only fonts like SF Mono and Menlo are +// harmless on other platforms (the browser skips them), while Cascadia Mono / +// Consolas cover Windows and DejaVu Sans Mono / Liberation Mono cover Linux. +const FALLBACK_FONTS = [ + 'SF Mono', // macOS 10.12+ + 'Menlo', // macOS (older) + 'Monaco', // macOS (legacy) + 'Cascadia Mono', // Windows 11+ + 'Consolas', // Windows Vista+ + 'DejaVu Sans Mono', // Linux (common) + 'Liberation Mono', // Linux (common) + 'monospace' // ultimate generic fallback +] as const + export function buildFontFamily(fontFamily: string): string { const trimmed = fontFamily.trim() const parts = trimmed ? [`"${trimmed}"`] : [] - // Always include fallbacks - if (!parts.some((p) => p.toLowerCase().includes('sf mono'))) { - parts.push('"SF Mono"') + const lowerParts = parts.map((p) => p.toLowerCase()) + // Append each fallback unless the user's font name already contains it + // (case-insensitive) to avoid duplicates like '"SF Mono", "SF Mono"'. + for (const fallback of FALLBACK_FONTS) { + const lower = fallback.toLowerCase() + if (!lowerParts.some((p) => p.includes(lower))) { + // Generic keywords like "monospace" are unquoted; named fonts are quoted. + parts.push(fallback === 'monospace' ? fallback : `"${fallback}"`) + } } - parts.push('Menlo', 'monospace') return parts.join(', ') } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 300175854de..bfc5f1059fc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -14,6 +14,7 @@ type PtyConnectionDeps = { pendingWritesRef: React.RefObject> isActiveRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> + onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void updateTabTitle: (tabId: string, title: string) => void updateTabPtyId: (tabId: string, ptyId: string) => void @@ -95,6 +96,20 @@ export function connectPanePty( const cols = pane.terminal.cols const rows = pane.terminal.rows + // Why: if fitAddon resolved to 0×0, the container likely has no layout + // dimensions (display:none, unmounted, or zero-size parent). Surface a + // diagnostic so the user sees something instead of a blank pane. + if (cols === 0 || rows === 0) { + deps.onPtyErrorRef?.current?.( + pane.id, + `Terminal has zero dimensions (${cols}×${rows}). The pane container may not be visible.` + ) + } + + const reportError = (message: string): void => { + deps.onPtyErrorRef?.current?.(pane.id, message) + } + const dataCallback = (data: string): void => { if (deps.isActiveRef.current) { pane.terminal.write(data) @@ -127,7 +142,8 @@ export function connectPanePty( cols, rows, callbacks: { - onData: dataCallback + onData: dataCallback, + onError: reportError } }) } else { @@ -144,7 +160,8 @@ export function connectPanePty( transport.sendInput(`${paneStartup.command}\r`) } }, - onData: dataCallback + onData: dataCallback, + onError: reportError } }) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 9cee90ce0c9..2cda43fc688 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -39,6 +39,7 @@ type UseTerminalPaneLifecycleDeps = { pendingWritesRef: React.RefObject> isActiveRef: React.RefObject onPtyExitRef: React.RefObject<(ptyId: string) => void> + onPtyErrorRef?: React.RefObject<(paneId: number, message: string) => void> clearTabPtyId: (tabId: string, ptyId: string) => void updateTabTitle: (tabId: string, title: string) => void updateTabPtyId: (tabId: string, ptyId: string) => void @@ -68,6 +69,7 @@ export function useTerminalPaneLifecycle({ pendingWritesRef, isActiveRef, onPtyExitRef, + onPtyErrorRef, clearTabPtyId, updateTabTitle, updateTabPtyId, @@ -158,6 +160,7 @@ export function useTerminalPaneLifecycle({ pendingWritesRef, isActiveRef, onPtyExitRef, + onPtyErrorRef, clearTabPtyId, updateTabTitle, updateTabPtyId, @@ -239,7 +242,7 @@ export function useTerminalPaneLifecycle({ const terminalFontWeights = resolveTerminalFontWeights(currentSettings?.terminalFontWeight) return { fontSize: currentSettings?.terminalFontSize ?? 14, - fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? 'SF Mono'), + fontFamily: buildFontFamily(currentSettings?.terminalFontFamily ?? ''), fontWeight: terminalFontWeights.fontWeight, fontWeightBold: terminalFontWeights.fontWeightBold, scrollback: Math.min( diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index 766b48c0d8d..c60349925a8 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -49,7 +49,11 @@ export function createPaneDOM( cursorBlink: true, cursorStyle: 'bar', fontSize: 14, - fontFamily: '"SF Mono", Menlo, monospace', + // Cross-platform fallback chain — ensures the terminal can always find a + // usable monospace font regardless of OS, even if user settings haven't + // loaded yet. macOS-only fonts are harmlessly skipped on other platforms. + fontFamily: + '"SF Mono", "Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", monospace', fontWeight: '300', fontWeightBold: '500', scrollback: 10000, @@ -172,13 +176,19 @@ export function attachWebgl(pane: ManagedPaneInternal): void { try { const webglAddon = new WebglAddon() webglAddon.onContextLoss(() => { + console.warn( + '[terminal] WebGL context lost for pane', + pane.id, + '— falling back to DOM renderer' + ) webglAddon.dispose() pane.webglAddon = null }) pane.terminal.loadAddon(webglAddon) pane.webglAddon = webglAddon - } catch { - // WebGL not available — default DOM renderer is fine + } catch (err) { + // WebGL not available — default DOM renderer is fine, but log it for debugging + console.warn('[terminal] WebGL unavailable for pane', pane.id, '— using DOM renderer:', err) pane.webglAddon = null } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 5b9d5883c5e..783a3d59a94 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -9,6 +9,20 @@ import type { import { DEFAULT_TERMINAL_FONT_WEIGHT } from './terminal-fonts' export const SCHEMA_VERSION = 1 + +// Pick a default terminal font that is likely to exist on the current OS. +// buildFontFamily() adds the full cross-platform fallback chain, so this only +// affects what users see in Settings as the initial value. +function defaultTerminalFontFamily(): string { + const platform = typeof process !== 'undefined' ? process.platform : '' + if (platform === 'win32') { + return 'Cascadia Mono' + } + if (platform === 'linux') { + return 'DejaVu Sans Mono' + } + return 'SF Mono' // macOS default +} export const DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS = 1000 export const MIN_EDITOR_AUTO_SAVE_DELAY_MS = 250 export const MAX_EDITOR_AUTO_SAVE_DELAY_MS = 10_000 @@ -43,7 +57,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings { editorAutoSave: false, editorAutoSaveDelayMs: DEFAULT_EDITOR_AUTO_SAVE_DELAY_MS, terminalFontSize: 14, - terminalFontFamily: 'SF Mono', + terminalFontFamily: defaultTerminalFontFamily(), terminalFontWeight: DEFAULT_TERMINAL_FONT_WEIGHT, terminalCursorStyle: 'bar', terminalCursorBlink: true,