feat(terminal): add terminalJISYenToBackslash setting (#2818)

This commit is contained in:
Yusuke Hata 2026-05-26 16:50:15 +09:00 committed by GitHub
parent ddda84f529
commit 36b4b726fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 241 additions and 51 deletions

View File

@ -114,6 +114,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
keepComputerAwakeWhileAgentsRun: false,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
terminalJISYenToBackslash: false,
experimentalMobile: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,

View File

@ -101,6 +101,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
keepComputerAwakeWhileAgentsRun: false,
terminalMacOptionAsAlt: 'false',
terminalMacOptionAsAltMigrated: true,
terminalJISYenToBackslash: false,
experimentalMobile: false,
mobileAutoRestoreFitMs: null,
experimentalPet: false,

View File

@ -44,6 +44,7 @@ import {
TERMINAL_DARK_THEME_SEARCH_ENTRIES,
TERMINAL_LIGHT_THEME_SEARCH_ENTRIES,
TERMINAL_MAC_OPTION_SEARCH_ENTRIES,
TERMINAL_MAC_YEN_SEARCH_ENTRIES,
TERMINAL_PANE_STYLE_SEARCH_ENTRIES,
TERMINAL_RENDERING_SEARCH_ENTRIES,
TERMINAL_SETUP_SCRIPT_SEARCH_ENTRIES,
@ -721,7 +722,9 @@ export function TerminalPane({
searchQuery,
TERMINAL_WINDOWS_POWERSHELL_IMPLEMENTATION_SEARCH_ENTRY
)) ||
(isMac && matchesSettingsSearch(searchQuery, TERMINAL_MAC_OPTION_SEARCH_ENTRIES)) ? (
(isMac &&
(matchesSettingsSearch(searchQuery, TERMINAL_MAC_OPTION_SEARCH_ENTRIES) ||
matchesSettingsSearch(searchQuery, TERMINAL_MAC_YEN_SEARCH_ENTRIES))) ? (
<section key="advanced" className="space-y-3">
<SettingsSubsectionHeader
title="Advanced"
@ -886,53 +889,82 @@ export function TerminalPane({
) : null}
{isMac ? (
<SearchableSetting
title="Option as Alt"
description="Controls whether the macOS Option key sends Alt/Esc sequences or composes characters."
keywords={[
'terminal',
'option',
'alt',
'key',
'meta',
'compose',
'mac',
'macos',
'keyboard',
'german',
'international',
'readline',
'ghostty'
]}
>
<SettingsRow
alignTop
label="Option as Alt"
description={
settings.terminalMacOptionAsAlt === 'auto'
? `Auto — detected: ${detectedLayoutLabel}.`
: settings.terminalMacOptionAsAlt === 'false'
? 'Option composes special characters for your keyboard layout.'
: settings.terminalMacOptionAsAlt === 'true'
? 'Both Option keys send Alt/Esc sequences.'
: `The ${settings.terminalMacOptionAsAlt} Option key sends Alt/Esc; the other composes special characters.`
}
control={
<SettingsSegmentedControl
ariaLabel="Option as Alt"
value={settings.terminalMacOptionAsAlt}
onChange={(option) => updateSettings({ terminalMacOptionAsAlt: option })}
options={[
{ value: 'auto', label: 'Auto' },
{ value: 'true', label: 'Both' },
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
{ value: 'false', label: 'Off' }
]}
/>
}
/>
</SearchableSetting>
<>
<SearchableSetting
title="Option as Alt"
description="Controls whether the macOS Option key sends Alt/Esc sequences or composes characters."
keywords={[
'terminal',
'option',
'alt',
'key',
'meta',
'compose',
'mac',
'macos',
'keyboard',
'german',
'international',
'readline',
'ghostty'
]}
>
<SettingsRow
alignTop
label="Option as Alt"
description={
settings.terminalMacOptionAsAlt === 'auto'
? `Auto — detected: ${detectedLayoutLabel}.`
: settings.terminalMacOptionAsAlt === 'false'
? 'Option composes special characters for your keyboard layout.'
: settings.terminalMacOptionAsAlt === 'true'
? 'Both Option keys send Alt/Esc sequences.'
: `The ${settings.terminalMacOptionAsAlt} Option key sends Alt/Esc; the other composes special characters.`
}
control={
<SettingsSegmentedControl
ariaLabel="Option as Alt"
value={settings.terminalMacOptionAsAlt}
onChange={(option) => updateSettings({ terminalMacOptionAsAlt: option })}
options={[
{ value: 'auto', label: 'Auto' },
{ value: 'true', label: 'Both' },
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
{ value: 'false', label: 'Off' }
]}
/>
}
/>
</SearchableSetting>
<SearchableSetting
title="JIS Yen (¥) to Backslash (\\)"
description="Controls whether pressing the JIS Yen (¥) key sends a backslash (\\) instead."
keywords={[
'terminal',
'yen',
'backslash',
'japanese',
'keyboard',
'mac',
'macos',
'jis',
'intl'
]}
>
<SettingsSwitchRow
label="JIS Yen (¥) to Backslash (\\)"
description="Pressing the JIS Yen (¥) key sends a backslash (\\) instead."
checked={settings.terminalJISYenToBackslash ?? false}
onChange={() =>
updateSettings({
terminalJISYenToBackslash: !settings.terminalJISYenToBackslash
})
}
/>
</SearchableSetting>
</>
) : null}
</div>
</section>

View File

@ -32,6 +32,16 @@ describe('getTerminalPaneSearchEntries', () => {
expect(entries.some((entry) => entry.title === 'Option as Alt')).toBe(false)
})
it('includes the JIS Yen mapping setting only on macOS', () => {
const entriesMac = getTerminalPaneSearchEntries({ isWindows: false, isMac: true })
const entriesLinux = getTerminalPaneSearchEntries({ isWindows: false, isMac: false })
expect(entriesMac.some((entry) => entry.title === 'JIS Yen (¥) to Backslash (\\)')).toBe(true)
expect(entriesLinux.some((entry) => entry.title === 'JIS Yen (¥) to Backslash (\\)')).toBe(
false
)
})
it('includes the Manage Sessions entry on all platforms', () => {
const entriesWindows = getTerminalPaneSearchEntries({ isWindows: true, isMac: false })
const entriesMac = getTerminalPaneSearchEntries({ isWindows: false, isMac: true })

View File

@ -181,6 +181,24 @@ export const TERMINAL_MAC_OPTION_SEARCH_ENTRIES: SettingsSearchEntry[] = [
}
]
export const TERMINAL_MAC_YEN_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'JIS Yen (¥) to Backslash (\\)',
description: 'Controls whether pressing the JIS Yen (¥) key sends a backslash (\\) instead.',
keywords: [
'terminal',
'yen',
'backslash',
'japanese',
'keyboard',
'mac',
'macos',
'jis',
'intl'
]
}
]
export const TERMINAL_GHOSTTY_IMPORT_SEARCH_ENTRIES: SettingsSearchEntry[] = [
{
title: 'Import from Ghostty',
@ -286,6 +304,8 @@ export function getTerminalPaneSearchEntries(platform: {
...TERMINAL_GHOSTTY_IMPORT_SEARCH_ENTRIES,
...MANAGE_SESSIONS_SEARCH_ENTRIES,
...TERMINAL_ADVANCED_SEARCH_ENTRIES,
...(platform.isMac ? TERMINAL_MAC_OPTION_SEARCH_ENTRIES : [])
...(platform.isMac
? [...TERMINAL_MAC_OPTION_SEARCH_ENTRIES, ...TERMINAL_MAC_YEN_SEARCH_ENTRIES]
: [])
]
}

View File

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { resolveTerminalJisYenInput, type TerminalJisYenInputEvent } from './terminal-jis-yen-input'
function event(overrides: Partial<TerminalJisYenInputEvent>): TerminalJisYenInputEvent {
return {
type: 'keydown',
key: '¥',
code: 'IntlYen',
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...overrides
}
}
describe('resolveTerminalJisYenInput', () => {
const enabledOnMac = { enabled: true, isMac: true }
it('translates a plain physical JIS Yen keydown to backslash on macOS', () => {
expect(resolveTerminalJisYenInput(event({}), enabledOnMac)).toEqual({
type: 'input',
data: '\\'
})
})
it('suppresses companion events after the translated keydown', () => {
expect(resolveTerminalJisYenInput(event({ type: 'keypress' }), enabledOnMac)).toEqual({
type: 'suppress'
})
expect(resolveTerminalJisYenInput(event({ type: 'keyup' }), enabledOnMac)).toEqual({
type: 'suppress'
})
})
it('does not rewrite arbitrary yen text from another physical key', () => {
expect(resolveTerminalJisYenInput(event({ code: 'KeyY' }), enabledOnMac)).toBeNull()
})
it('does not rewrite modified JIS Yen chords', () => {
const modifiedCases = [
event({ metaKey: true }),
event({ ctrlKey: true }),
event({ altKey: true }),
event({ shiftKey: true })
]
for (const input of modifiedCases) {
expect(resolveTerminalJisYenInput(input, enabledOnMac)).toBeNull()
}
})
it('is gated by both the user setting and macOS', () => {
expect(resolveTerminalJisYenInput(event({}), { enabled: false, isMac: true })).toBeNull()
expect(resolveTerminalJisYenInput(event({}), { enabled: true, isMac: false })).toBeNull()
})
})

View File

@ -0,0 +1,50 @@
export type TerminalJisYenInputEvent = {
type: string
key: string
code?: string
metaKey: boolean
ctrlKey: boolean
altKey: boolean
shiftKey: boolean
}
export type TerminalJisYenInputOptions = {
enabled: boolean
isMac: boolean
}
export type TerminalJisYenInputAction = { type: 'input'; data: string } | { type: 'suppress' }
function isPlainPhysicalJisYenKey(event: TerminalJisYenInputEvent): boolean {
// Why: event.key='¥' can come from input methods or other layouts; IntlYen
// scopes the rewrite to the physical JIS key the setting names.
return (
event.code === 'IntlYen' &&
event.key === '¥' &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey
)
}
export function resolveTerminalJisYenInput(
event: TerminalJisYenInputEvent,
options: TerminalJisYenInputOptions
): TerminalJisYenInputAction | null {
if (!options.enabled || !options.isMac || !isPlainPhysicalJisYenKey(event)) {
return null
}
if (event.type === 'keydown') {
return { type: 'input', data: '\\' }
}
if (event.type === 'keypress' || event.type === 'keyup') {
// Why: suppress companion events so the translated keydown cannot be
// followed by a browser text event or xterm key-release sequence for ¥.
return { type: 'suppress' }
}
return null
}

View File

@ -35,6 +35,7 @@ import {
} from './terminal-appearance'
import { parseOsc52 } from './osc52-clipboard'
import { parseOsc7 } from './parse-osc7'
import { resolveTerminalJisYenInput } from './terminal-jis-yen-input'
import { shouldBypassXtermKeyboardEvent } from './xterm-bypass-policy'
import type { PaneCwdMap } from './resolve-split-cwd'
import { installMouseHideWhileTyping } from './mouse-hide-while-typing'
@ -515,10 +516,24 @@ export function useTerminalPaneLifecycle({
// matching keyups so kitty release sequences do not leak after a
// bypassed press. Returning false here short-circuits xterm before the
// encoder runs, letting the browser and Electron paths fire normally.
// See xterm-bypass-policy.ts for the rule derivation (Ghostty/VS Code).
// See xterm-bypass-policy.ts for the rule derivation.
pane.terminal.attachCustomKeyEventHandler((e) => {
const isMac = navigator.userAgent.includes('Mac')
const jisYenInput = resolveTerminalJisYenInput(e, {
enabled: settingsRef.current?.terminalJISYenToBackslash === true,
isMac
})
if (jisYenInput) {
if (jisYenInput.type === 'input') {
// Why: this is a translated character, not a terminal shortcut.
// Keep it on xterm's onData path so PTY input guards still run.
pane.terminal.input(jisYenInput.data)
}
return false
}
return !shouldBypassXtermKeyboardEvent(e, {
isMac: navigator.userAgent.includes('Mac'),
isMac,
hasSelection: pane.terminal.hasSelection()
})
})

View File

@ -260,6 +260,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// the box (issue #903) while US users keep Option-as-Alt readline chords.
terminalMacOptionAsAlt: 'auto',
terminalMacOptionAsAltMigrated: false,
terminalJISYenToBackslash: false,
experimentalMobile: false,
// Why: indefinite hold by default — the desktop "Restore" banner is the
// explicit return-to-desktop-size action, no wall-clock guess.

View File

@ -1798,6 +1798,9 @@ export type GlobalSettings = {
* detection, so no visible behavior change. Then we flip this flag to true
* and never migrate again. */
terminalMacOptionAsAltMigrated: boolean
/** Controls whether macOS terminal input translates the physical JIS Yen (¥)
* key to a backslash, matching the common terminal expectation for that key. */
terminalJISYenToBackslash: boolean
experimentalMobile: boolean
/** Auto-restore window for a phone-fit PTY after the last mobile
* subscriber leaves. `null` (default) holds the PTY at phone size