feat(terminal): add terminalJISYenToBackslash setting (#2818)
This commit is contained in:
parent
ddda84f529
commit
36b4b726fa
|
|
@ -114,6 +114,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
keepComputerAwakeWhileAgentsRun: false,
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
terminalJISYenToBackslash: false,
|
||||
experimentalMobile: false,
|
||||
mobileAutoRestoreFitMs: null,
|
||||
experimentalPet: false,
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
|||
keepComputerAwakeWhileAgentsRun: false,
|
||||
terminalMacOptionAsAlt: 'false',
|
||||
terminalMacOptionAsAltMigrated: true,
|
||||
terminalJISYenToBackslash: false,
|
||||
experimentalMobile: false,
|
||||
mobileAutoRestoreFitMs: null,
|
||||
experimentalPet: false,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
: [])
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue