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
This commit is contained in:
Jinjing
2026-04-06 14:33:33 -07:00
committed by GitHub
parent fb7ea89e7e
commit 6c423f15f0
9 changed files with 186 additions and 32 deletions
+23 -14
View File
@@ -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<string, string>
})
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<string, string>
})
} 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))
@@ -0,0 +1,58 @@
export function TerminalErrorToast({
error,
onDismiss
}: {
error: string
onDismiss: () => void
}): React.JSX.Element {
return (
<div
style={{
position: 'absolute',
bottom: 12,
left: 12,
right: 12,
zIndex: 50,
padding: '10px 14px',
borderRadius: 6,
background: 'rgba(220, 38, 38, 0.15)',
border: '1px solid rgba(220, 38, 38, 0.4)',
color: '#fca5a5',
fontSize: 12,
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
pointerEvents: 'auto'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start' }}>
<span>
{error}
{'\n'}
If this persists, please{' '}
<a
href="https://github.com/stablyai/orca/issues"
style={{ color: '#fca5a5', textDecoration: 'underline' }}
>
file an issue
</a>
.
</span>
<button
onClick={onDismiss}
style={{
background: 'none',
border: 'none',
color: '#fca5a5',
cursor: 'pointer',
fontSize: 14,
padding: '0 0 0 8px',
lineHeight: 1,
flexShrink: 0
}}
>
×
</button>
</div>
</div>
)
}
@@ -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<number | null>(null)
const [searchOpen, setSearchOpen] = useState(false)
const [closeConfirmPaneId, setCloseConfirmPaneId] = useState<number | null>(null)
const [terminalError, setTerminalError] = useState<string | null>(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 && (
<TerminalErrorToast error={terminalError} onDismiss={() => setTerminalError(null)} />
)}
{activePane?.container &&
createPortal(
<TerminalSearch
@@ -78,30 +78,44 @@ describe('paneLeafId', () => {
// ---------------------------------------------------------------------------
// 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'
)
})
})
@@ -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(', ')
}
@@ -14,6 +14,7 @@ type PtyConnectionDeps = {
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
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
}
})
}
@@ -39,6 +39,7 @@ type UseTerminalPaneLifecycleDeps = {
pendingWritesRef: React.RefObject<Map<number, string>>
isActiveRef: React.RefObject<boolean>
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(
@@ -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
}
}
+15 -1
View File
@@ -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,