feat: Ghostty-style macOS Option key mode setting (#748)

* feat: add Ghostty-style macOS Option key mode setting

Disable xterm's macOptionIsMeta by default so non-US keyboard layouts
can compose characters (e.g. @ on German, € on French). Add a
user-facing "Option as Alt" setting with four modes — Both, Left,
Right, Off — mirroring Ghostty's macos-option-as-alt. Core readline
shortcuts (Option+B/F/D) are automatically compensated when Option is
in compose mode. The setting applies live to existing terminal panes.

* fix: track Option key location from modifier keydown, not character key

KeyboardEvent.location on a character key (e.g. Period) reports that
key's own position (always 0), not which modifier is held. Left/right
Option mode was broken because we read location from the character
event. Fix by recording the Option key's location from its own
keydown/keyup events and passing it as a separate parameter.
This commit is contained in:
Jinwoo Hong 2026-04-17 01:37:22 -04:00 committed by GitHub
parent 42e01f240c
commit 39832c7801
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 378 additions and 44 deletions

View File

@ -15,7 +15,7 @@ import type { OrcaHooks } from '../../../../shared/types'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import { useAppStore } from '../../store'
import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark'
import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { SCROLLBACK_PRESETS_MB, getFallbackTerminalFonts } from './SettingsConstants'
import { GeneralPane, GENERAL_PANE_SEARCH_ENTRIES } from './GeneralPane'
import { AppearancePane, APPEARANCE_PANE_SEARCH_ENTRIES } from './AppearancePane'
@ -82,12 +82,13 @@ function Settings(): React.JSX.Element {
>({})
const systemPrefersDark = useSystemPrefersDark()
const isWindows = isWindowsUserAgent()
const isMac = isMacUserAgent()
// Why: the Terminal settings section shares one search index with the
// sidebar. We trim Windows-only entries on other platforms so search never
// sidebar. We trim platform-only entries on other platforms so search never
// reveals controls that the renderer will intentionally hide.
const terminalPaneSearchEntries = useMemo(
() => getTerminalPaneSearchEntries(isWindows),
[isWindows]
() => getTerminalPaneSearchEntries({ isWindows, isMac }),
[isWindows, isMac]
)
const [scrollbackMode, setScrollbackMode] = useState<'preset' | 'custom'>('preset')
const [prevScrollbackBytes, setPrevScrollbackBytes] = useState(settings?.terminalScrollbackBytes)

View File

@ -26,12 +26,13 @@ import { SCROLLBACK_PRESETS_MB } from './SettingsConstants'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
import { useAppStore } from '../../store'
import { isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers'
import {
TERMINAL_ADVANCED_SEARCH_ENTRIES,
TERMINAL_CURSOR_SEARCH_ENTRIES,
TERMINAL_DARK_THEME_SEARCH_ENTRIES,
TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
TERMINAL_MAC_OPTION_SEARCH_ENTRIES,
TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
TERMINAL_RIGHT_CLICK_TO_PASTE_SEARCH_ENTRY,
TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
@ -58,6 +59,7 @@ export function TerminalPane({
}: TerminalPaneProps): React.JSX.Element {
const searchQuery = useAppStore((state) => state.settingsSearchQuery)
const isWindows = isWindowsUserAgent()
const isMac = isMacUserAgent()
const [themeSearchDark, setThemeSearchDark] = useState('')
const [themeSearchLight, setThemeSearchLight] = useState('')
@ -462,7 +464,8 @@ export function TerminalPane({
</SearchableSetting>
</section>
) : null,
matchesSettingsSearch(searchQuery, TERMINAL_ADVANCED_SEARCH_ENTRIES) ? (
matchesSettingsSearch(searchQuery, TERMINAL_ADVANCED_SEARCH_ENTRIES) ||
(isMac && matchesSettingsSearch(searchQuery, TERMINAL_MAC_OPTION_SEARCH_ENTRIES)) ? (
<section key="advanced" className="space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Advanced</h3>
@ -532,6 +535,59 @@ export function TerminalPane({
/>
) : null}
</SearchableSetting>
{isMac ? (
<SearchableSetting
title="Option as Alt"
description="Controls whether the macOS Option key sends Alt/Esc sequences or composes characters. Mirrors Ghostty's macos-option-as-alt."
keywords={[
'terminal',
'option',
'alt',
'key',
'meta',
'compose',
'mac',
'macos',
'keyboard',
'german',
'international',
'readline',
'ghostty'
]}
className="space-y-2"
>
<Label>Option as Alt</Label>
<div className="flex w-fit gap-1 rounded-md border border-border/50 p-1">
{(['true', 'left', 'right', 'false'] as const).map((option) => (
<button
key={option}
onClick={() => updateSettings({ terminalMacOptionAsAlt: option })}
className={`rounded-sm px-3 py-1 text-sm transition-colors ${
settings.terminalMacOptionAsAlt === option
? 'bg-accent font-medium text-accent-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{option === 'false'
? 'Off'
: option === 'true'
? 'Both'
: option === 'left'
? 'Left'
: 'Right'}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
{settings.terminalMacOptionAsAlt === 'false'
? 'Option composes special characters for your keyboard layout. Core readline shortcuts (Option+B/F/D) are handled automatically.'
: settings.terminalMacOptionAsAlt === 'true'
? 'Both Option keys send Alt/Esc sequences for full readline and shell support. Special character input via Option is unavailable.'
: `The ${settings.terminalMacOptionAsAlt} Option key sends Alt/Esc sequences; the other composes special characters.`}
</p>
</SearchableSetting>
) : null}
</section>
) : null
].filter(Boolean)

View File

@ -3,12 +3,22 @@ import { getTerminalPaneSearchEntries } from './terminal-search'
describe('getTerminalPaneSearchEntries', () => {
it('includes the Windows right-click setting on Windows', () => {
const entries = getTerminalPaneSearchEntries(true)
const entries = getTerminalPaneSearchEntries({ isWindows: true, isMac: false })
expect(entries.some((entry) => entry.title === 'Right-click to paste')).toBe(true)
})
it('omits the Windows right-click setting elsewhere', () => {
const entries = getTerminalPaneSearchEntries(false)
const entries = getTerminalPaneSearchEntries({ isWindows: false, isMac: false })
expect(entries.some((entry) => entry.title === 'Right-click to paste')).toBe(false)
})
it('includes the Option as Alt setting on macOS', () => {
const entries = getTerminalPaneSearchEntries({ isWindows: false, isMac: true })
expect(entries.some((entry) => entry.title === 'Option as Alt')).toBe(true)
})
it('omits the Option as Alt setting on non-macOS', () => {
const entries = getTerminalPaneSearchEntries({ isWindows: false, isMac: false })
expect(entries.some((entry) => entry.title === 'Option as Alt')).toBe(false)
})
})

View File

@ -89,6 +89,29 @@ export const TERMINAL_ADVANCED_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const TERMINAL_MAC_OPTION_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Option as Alt',
description:
"Controls whether the macOS Option key sends Alt/Esc sequences or composes characters. Mirrors Ghostty's macos-option-as-alt.",
keywords: [
'terminal',
'option',
'alt',
'key',
'meta',
'compose',
'mac',
'macos',
'keyboard',
'german',
'international',
'readline',
'ghostty'
]
}
]
export const TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Setup Script Location',
@ -120,18 +143,22 @@ export const TERMINAL_WINDOWS_SEARCH_ENTRIES: SettingsSearchEntry[] = [
export const TERMINAL_RIGHT_CLICK_TO_PASTE_SEARCH_ENTRY = TERMINAL_WINDOWS_SEARCH_ENTRIES
export function getTerminalPaneSearchEntries(isWindows: boolean): SettingsSearchEntry[] {
export function getTerminalPaneSearchEntries(platform: {
isWindows: boolean
isMac: boolean
}): SettingsSearchEntry[] {
// Why: the settings search index must mirror the visible controls. Keeping
// the Windows-only paste toggle out of non-Windows search results prevents
// platform-only controls out of other platforms' search results prevents
// users from landing on an option the UI intentionally hides.
return [
...TERMINAL_TYPOGRAPHY_SEARCH_ENTRIES,
...TERMINAL_CURSOR_SEARCH_ENTRIES,
...TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
...(isWindows ? TERMINAL_WINDOWS_SEARCH_ENTRIES : []),
...(platform.isWindows ? TERMINAL_WINDOWS_SEARCH_ENTRIES : []),
...TERMINAL_DARK_THEME_SEARCH_ENTRIES,
...TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
...TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
...TERMINAL_ADVANCED_SEARCH_ENTRIES
...TERMINAL_ADVANCED_SEARCH_ENTRIES,
...(platform.isMac ? TERMINAL_MAC_OPTION_SEARCH_ENTRIES : [])
]
}

View File

@ -16,6 +16,7 @@ import { fitPanes, isWindowsUserAgent, shellEscapePath } from './pane-helpers'
import { EMPTY_LAYOUT, paneLeafId, serializeTerminalLayout } from './layout-serialization'
import { createExpandCollapseActions } from './expand-collapse'
import { useTerminalKeyboardShortcuts, type SearchState } from './keyboard-handlers'
import type { MacOptionAsAlt } from './terminal-shortcut-policy'
import { useTerminalFontZoom } from './useTerminalFontZoom'
import CloseTerminalDialog from './CloseTerminalDialog'
import { TerminalErrorToast } from './TerminalErrorToast'
@ -146,6 +147,8 @@ export default function TerminalPane({
const settingsRef = useRef(settings)
settingsRef.current = settings
const macOptionAsAltRef = useRef<MacOptionAsAlt>(settings?.terminalMacOptionAsAlt ?? 'false')
macOptionAsAltRef.current = settings?.terminalMacOptionAsAlt ?? 'false'
const onPtyExitRef = useRef(onPtyExit)
onPtyExitRef.current = onPtyExit
@ -463,7 +466,8 @@ export default function TerminalPane({
setSearchOpen,
onRequestClosePane: handleRequestClosePane,
searchOpenRef,
searchStateRef
searchStateRef,
macOptionAsAltRef
})
useTerminalPaneGlobalEffects({

View File

@ -2,6 +2,7 @@ import { useEffect } from 'react'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyTransport } from './pty-transport'
import { resolveTerminalShortcutAction } from './terminal-shortcut-policy'
import type { MacOptionAsAlt } from './terminal-shortcut-policy'
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) {
@ -75,6 +76,7 @@ type KeyboardHandlersDeps = {
onRequestClosePane: (paneId: number) => void
searchOpenRef: React.RefObject<boolean>
searchStateRef: React.RefObject<SearchState>
macOptionAsAltRef: React.RefObject<MacOptionAsAlt>
}
export function useTerminalKeyboardShortcuts({
@ -90,7 +92,8 @@ export function useTerminalKeyboardShortcuts({
setSearchOpen,
onRequestClosePane,
searchOpenRef,
searchStateRef
searchStateRef,
macOptionAsAltRef
}: KeyboardHandlersDeps): void {
useEffect(() => {
if (!isActive) {
@ -98,6 +101,23 @@ export function useTerminalKeyboardShortcuts({
}
const isMac = navigator.userAgent.includes('Mac')
// Why: KeyboardEvent.location on a character key (e.g. Period) always
// reports that key's own position (0 = standard), not which modifier is
// held. To distinguish left vs right Option, we record the Option key's
// location from its own keydown event and clear it on keyup.
let optionKeyLocation = 0
const onModifierDown = (e: KeyboardEvent): void => {
if (e.key === 'Alt') {
optionKeyLocation = e.location
}
}
const onModifierUp = (e: KeyboardEvent): void => {
if (e.key === 'Alt') {
optionKeyLocation = 0
}
}
const onKeyDown = (e: KeyboardEvent): void => {
const manager = managerRef.current
if (!manager) {
@ -133,7 +153,12 @@ export function useTerminalKeyboardShortcuts({
return
}
const action = resolveTerminalShortcutAction(e, isMac)
const action = resolveTerminalShortcutAction(
e,
isMac,
macOptionAsAltRef.current,
optionKeyLocation
)
if (!action) {
return
}
@ -272,8 +297,12 @@ export function useTerminalKeyboardShortcuts({
}
}
window.addEventListener('keydown', onModifierDown, { capture: true })
window.addEventListener('keyup', onModifierUp, { capture: true })
window.addEventListener('keydown', onKeyDown, { capture: true })
return () => {
window.removeEventListener('keydown', onModifierDown, { capture: true })
window.removeEventListener('keyup', onModifierUp, { capture: true })
window.removeEventListener('keydown', onKeyDown, { capture: true })
}
}, [
@ -289,6 +318,7 @@ export function useTerminalKeyboardShortcuts({
setSearchOpen,
onRequestClosePane,
searchOpenRef,
searchStateRef
searchStateRef,
macOptionAsAltRef
])
}

View File

@ -65,6 +65,12 @@ export function isWindowsUserAgent(
return userAgent.includes('Windows')
}
export function isMacUserAgent(
userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent
): boolean {
return userAgent.includes('Mac')
}
export function shellEscapePath(
path: string,
userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent

View File

@ -34,6 +34,7 @@ export function applyTerminalAppearance(
pane.terminal.options.fontFamily = buildFontFamily(settings.terminalFontFamily)
pane.terminal.options.fontWeight = terminalFontWeights.fontWeight
pane.terminal.options.fontWeightBold = terminalFontWeights.fontWeightBold
pane.terminal.options.macOptionIsMeta = settings.terminalMacOptionAsAlt === 'true'
try {
// Why: preserve scroll-to-bottom state across the reflow so appearance
// changes (theme, font size, etc.) don't make the terminal scroll up.

View File

@ -18,17 +18,14 @@ function event(overrides: Partial<TerminalShortcutEvent>): TerminalShortcutEvent
}
describe('resolveTerminalShortcutAction', () => {
it('preserves macOS readline and alt-word chords for the shell', () => {
it('preserves macOS readline ctrl chords for the shell', () => {
const passthroughCases = [
event({ key: 'r', code: 'KeyR', ctrlKey: true }),
event({ key: 'u', code: 'KeyU', ctrlKey: true }),
event({ key: 'e', code: 'KeyE', ctrlKey: true }),
event({ key: 'a', code: 'KeyA', ctrlKey: true }),
event({ key: 'w', code: 'KeyW', ctrlKey: true }),
event({ key: 'k', code: 'KeyK', ctrlKey: true }),
event({ key: 'b', code: 'KeyB', altKey: true }),
event({ key: 'f', code: 'KeyF', altKey: true }),
event({ key: 'd', code: 'KeyD', altKey: true })
event({ key: 'k', code: 'KeyK', ctrlKey: true })
]
for (const input of passthroughCases) {
@ -195,6 +192,112 @@ describe('resolveTerminalShortcutAction', () => {
).toBeNull()
})
it('translates macOS Option+B/F/D to readline escape sequences in compose mode', () => {
// With macOptionAsAlt='false' (compose), xterm.js doesn't translate these.
// Matches on event.code because macOS composition replaces event.key.
expect(
resolveTerminalShortcutAction(event({ key: '∫', code: 'KeyB', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bb' })
expect(
resolveTerminalShortcutAction(event({ key: 'ƒ', code: 'KeyF', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bf' })
expect(
resolveTerminalShortcutAction(event({ key: '∂', code: 'KeyD', altKey: true }), true, 'false')
).toEqual({ type: 'sendInput', data: '\x1bd' })
// On Linux/Windows, Alt+B/F/D must still pass through
expect(
resolveTerminalShortcutAction(event({ key: 'b', code: 'KeyB', altKey: true }), false)
).toBeNull()
// Option+Shift+B/F/D should not be intercepted (different chord)
expect(
resolveTerminalShortcutAction(
event({ key: 'B', code: 'KeyB', altKey: true, shiftKey: true }),
true,
'false'
)
).toBeNull()
})
it('sends Esc+letter for any Option+letter when left Option acts as alt', () => {
// Left Option (optionKeyLocation=1) in 'left' mode: full Meta for any letter key
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'left',
1
)
).toEqual({ type: 'sendInput', data: '\x1bl' })
expect(
resolveTerminalShortcutAction(
event({ key: '†', code: 'KeyT', altKey: true }),
true,
'left',
1
)
).toEqual({ type: 'sendInput', data: '\x1bt' })
// Right Option (optionKeyLocation=2) in 'left' mode: compose side, only B/F/D patched
expect(
resolveTerminalShortcutAction(
event({ key: '∫', code: 'KeyB', altKey: true }),
true,
'left',
2
)
).toEqual({ type: 'sendInput', data: '\x1bb' })
// Right Option+L should pass through (compose character)
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'left',
2
)
).toBeNull()
})
it('sends Esc+letter for any Option+letter when right Option acts as alt', () => {
// Right Option (optionKeyLocation=2) in 'right' mode: full Meta, including punctuation
expect(
resolveTerminalShortcutAction(
event({ key: '≥', code: 'Period', altKey: true }),
true,
'right',
2
)
).toEqual({ type: 'sendInput', data: '\x1b.' })
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'right',
2
)
).toEqual({ type: 'sendInput', data: '\x1bl' })
// Left Option (optionKeyLocation=1) in 'right' mode: compose side, only B/F/D patched
expect(
resolveTerminalShortcutAction(
event({ key: '¬', code: 'KeyL', altKey: true }),
true,
'right',
1
)
).toBeNull()
})
it('does not intercept Option+letter in true mode (xterm handles it)', () => {
// In 'true' mode, macOptionIsMeta is enabled in xterm, so no compensation needed
// Our handler still fires but is gated by macOptionAsAlt !== 'true'
expect(
resolveTerminalShortcutAction(event({ key: 'b', code: 'KeyB', altKey: true }), true, 'true')
).toBeNull()
})
it('keeps Cmd+D and Cmd+Shift+D for split on macOS', () => {
expect(
resolveTerminalShortcutAction(event({ key: 'd', code: 'KeyD', metaKey: true }), true)

View File

@ -8,6 +8,24 @@ export type TerminalShortcutEvent = {
repeat?: boolean
}
export type MacOptionAsAlt = 'true' | 'false' | 'left' | 'right'
// Why: macOS composition replaces event.key for punctuation, so we map
// event.code to the unmodified character for Esc+ sequences.
const PUNCTUATION_CODE_MAP: Record<string, string> = {
Period: '.',
Comma: ',',
Slash: '/',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
BracketLeft: '[',
BracketRight: ']',
Minus: '-',
Equal: '=',
Backquote: '`'
}
export type TerminalShortcutAction =
| { type: 'copySelection' }
| { type: 'toggleSearch' }
@ -20,7 +38,9 @@ export type TerminalShortcutAction =
export function resolveTerminalShortcutAction(
event: TerminalShortcutEvent,
isMac: boolean
isMac: boolean,
macOptionAsAlt: MacOptionAsAlt = 'false',
optionKeyLocation: number = 0
): TerminalShortcutAction | null {
const mod = isMac ? event.metaKey && !event.ctrlKey : event.ctrlKey && !event.metaKey
if (!event.repeat && mod && !event.altKey) {
@ -146,9 +166,57 @@ export function resolveTerminalShortcutAction(
return { type: 'sendInput', data: event.key === 'ArrowLeft' ? '\x1bb' : '\x1bf' }
}
// Why: the terminal shortcut layer is an explicit allowlist, not a generic
// "modifier means app shortcut" rule. Keeping this list narrow prevents Orca
// from swallowing readline/emacs control chords like Ctrl+R, Ctrl+U, Ctrl+E,
// Alt+B, Alt+F, and Alt+D when the shell owns terminal focus.
// Why: with macOptionIsMeta disabled (to let non-US keyboard layouts compose
// characters like @ and €), xterm.js no longer translates Option+letter into
// Esc+letter automatically. We match on event.code (physical key) rather than
// event.key because macOS composition replaces event.key with the composed
// character (e.g. Option+B reports key='∫', not key='b').
//
// The handling depends on the macOptionAsAlt setting (mirrors Ghostty):
// - 'true': xterm handles all Option as Meta natively; nothing to do here.
// - 'false': compensate the three most critical readline shortcuts (B/F/D).
// - 'left'/'right': the designated Option key acts as full Meta (emit Esc+
// for any single letter); the other key composes, with B/F/D compensated.
if (isMac && !event.metaKey && !event.ctrlKey && event.altKey && !event.shiftKey) {
// Why: event.location on a character key reports that key's position (always
// 0 for standard keys), NOT which modifier is held. The caller must track
// the Option key's own keydown location and pass it as optionKeyLocation.
const isLeftOption = optionKeyLocation === 1
const isRightOption = optionKeyLocation === 2
const shouldActAsMeta =
(macOptionAsAlt === 'left' && isLeftOption) || (macOptionAsAlt === 'right' && isRightOption)
if (shouldActAsMeta) {
// Emit Esc+key for letter keys (e.g. Option+B → \x1bb)
if (event.code?.startsWith('Key') && event.code.length === 4) {
const letter = event.code.charAt(3).toLowerCase()
return { type: 'sendInput', data: `\x1b${letter}` }
}
// Emit Esc+digit for number keys (e.g. Option+1 → \x1b1)
if (event.code?.startsWith('Digit') && event.code.length === 6) {
return { type: 'sendInput', data: `\x1b${event.code.charAt(5)}` }
}
const punct = event.code ? PUNCTUATION_CODE_MAP[event.code] : undefined
if (punct) {
return { type: 'sendInput', data: `\x1b${punct}` }
}
}
// In 'false', 'left', or 'right' mode, the compose-side Option key still
// needs the three most critical readline shortcuts patched.
if (macOptionAsAlt !== 'true' && !shouldActAsMeta) {
if (event.code === 'KeyB') {
return { type: 'sendInput', data: '\x1bb' }
}
if (event.code === 'KeyF') {
return { type: 'sendInput', data: '\x1bf' }
}
if (event.code === 'KeyD') {
return { type: 'sendInput', data: '\x1bd' }
}
}
}
return null
}

View File

@ -395,7 +395,8 @@ export function useTerminalPaneLifecycle({
)
),
cursorStyle: currentSettings?.terminalCursorStyle ?? 'bar',
cursorBlink: currentSettings?.terminalCursorBlink ?? true
cursorBlink: currentSettings?.terminalCursorBlink ?? true,
macOptionIsMeta: currentSettings?.terminalMacOptionAsAlt === 'true'
}
},
onLinkClick: (event, url) => {

View File

@ -0,0 +1,8 @@
import { describe, expect, it } from 'vitest'
import { buildDefaultTerminalOptions } from './pane-lifecycle'
describe('buildDefaultTerminalOptions', () => {
it('leaves macOS Option available for keyboard layout characters', () => {
expect(buildDefaultTerminalOptions().macOptionIsMeta).toBe(false)
})
})

View File

@ -19,6 +19,32 @@ import { safeFit } from './pane-tree-ops'
const ENABLE_WEBGL_RENDERER = true
export function buildDefaultTerminalOptions(): ITerminalOptions {
return {
allowProposedApi: true,
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 14,
// 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,
allowTransparency: false,
// Why: on macOS, non-US layouts rely on Option to compose real characters
// like @ (German Option+L) and € (German Option+E). Enabling xterm's
// Meta mode here makes Option behave like Esc+key instead, which steals
// those composed characters before they reach the shell.
// Readline shortcuts (Option+B/F/D) are compensated in terminal-shortcut-policy.ts.
macOptionIsMeta: false,
macOptionClickForcesSelection: true,
drawBoldTextInBrightColors: true
}
}
function getTerminalUrlOpenHint(): string {
return navigator.userAgent.includes('Mac')
? '⌘+click to open or ⇧⌘+click for system browser'
@ -48,22 +74,7 @@ export function createPaneDOM(
// Build terminal options
const userOpts = options.terminalOptions?.(id) ?? {}
const terminalOpts: ITerminalOptions = {
allowProposedApi: true,
cursorBlink: true,
cursorStyle: 'bar',
fontSize: 14,
// 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,
allowTransparency: false,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
drawBoldTextInBrightColors: true,
...buildDefaultTerminalOptions(),
...userOpts
}

View File

@ -121,7 +121,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
terminalScopeHistoryByWorktree: true,
defaultTuiAgent: null,
defaultTaskViewPreset: 'all',
agentCmdOverrides: {}
agentCmdOverrides: {},
terminalMacOptionAsAlt: 'true'
}
}

View File

@ -620,6 +620,13 @@ export type GlobalSettings = {
defaultTaskViewPreset: TaskViewPresetId
/** Per-agent CLI command overrides. A missing key means use the catalog default binary name. */
agentCmdOverrides: Partial<Record<TuiAgent, string>>
/** Why: macOS terminals must choose between letting Option compose layout
* characters (@ on German, on French) or treating Option as Meta/Esc for
* readline shortcuts. Mirrors Ghostty's macos-option-as-alt setting.
* 'false' = compose (default, for non-US keyboards);
* 'true' = full Meta on both Option keys;
* 'left' / 'right' = only that Option key acts as Meta, the other composes. */
terminalMacOptionAsAlt: 'true' | 'false' | 'left' | 'right'
}
export type NotificationEventSource = 'agent-task-complete' | 'terminal-bell' | 'test'