From 6c423f15f052117c7f6c19a05d5cc76317fc7e20 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:33:33 -0700 Subject: [PATCH] feat: improve terminal font compatibility across platforms (#342) * feat: improve terminal compatibility with default fonts across platforms Add cross-platform monospace font fallback chain (SF Mono, Menlo, Monaco, Cascadia Mono, Consolas, DejaVu Sans Mono, Liberation Mono) so the terminal renders correctly on macOS, Windows, and Linux out of the box. - Platform-aware default font in settings (SF Mono / Cascadia Mono / DejaVu) - buildFontFamily deduplicates user font against full fallback chain - Error handling for pty spawn failures with user-facing error banner - Zero-dimension diagnostic when terminal container has no layout - WebGL context loss / unavailability logging for debugging * feat: improve terminal font fallback and pty error handling - Add cross-platform terminal font fallbacks - Surface pty spawn errors to terminal UI - Fix max-lines eslint error in TerminalPane - Fix curly braces eslint error in constants.ts --- src/main/ipc/pty.ts | 37 +++++++----- .../terminal-pane/TerminalErrorToast.tsx | 58 +++++++++++++++++++ .../components/terminal-pane/TerminalPane.tsx | 9 +++ .../layout-serialization.test.ts | 28 ++++++--- .../terminal-pane/layout-serialization.ts | 28 +++++++-- .../terminal-pane/pty-connection.ts | 21 ++++++- .../use-terminal-pane-lifecycle.ts | 5 +- .../src/lib/pane-manager/pane-lifecycle.ts | 16 ++++- src/shared/constants.ts | 16 ++++- 9 files changed, 186 insertions(+), 32 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx 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,