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:
parent
42e01f240c
commit
39832c7801
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 : [])
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,8 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
|
|||
terminalScopeHistoryByWorktree: true,
|
||||
defaultTuiAgent: null,
|
||||
defaultTaskViewPreset: 'all',
|
||||
agentCmdOverrides: {}
|
||||
agentCmdOverrides: {},
|
||||
terminalMacOptionAsAlt: 'true'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue