diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 5f3038c3b..1293f0749 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -114,6 +114,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings keepComputerAwakeWhileAgentsRun: false, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, + terminalJISYenToBackslash: false, experimentalMobile: false, mobileAutoRestoreFitMs: null, experimentalPet: false, diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 6cae1ebdb..8024f8242 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -101,6 +101,7 @@ function createSettings(overrides: Partial = {}): GlobalSettings keepComputerAwakeWhileAgentsRun: false, terminalMacOptionAsAlt: 'false', terminalMacOptionAsAltMigrated: true, + terminalJISYenToBackslash: false, experimentalMobile: false, mobileAutoRestoreFitMs: null, experimentalPet: false, diff --git a/src/renderer/src/components/settings/TerminalPane.tsx b/src/renderer/src/components/settings/TerminalPane.tsx index 6a6b1c03b..2c6eb01e2 100644 --- a/src/renderer/src/components/settings/TerminalPane.tsx +++ b/src/renderer/src/components/settings/TerminalPane.tsx @@ -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))) ? (
- updateSettings({ terminalMacOptionAsAlt: option })} - options={[ - { value: 'auto', label: 'Auto' }, - { value: 'true', label: 'Both' }, - { value: 'left', label: 'Left' }, - { value: 'right', label: 'Right' }, - { value: 'false', label: 'Off' } - ]} - /> - } - /> - + <> + + updateSettings({ terminalMacOptionAsAlt: option })} + options={[ + { value: 'auto', label: 'Auto' }, + { value: 'true', label: 'Both' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + { value: 'false', label: 'Off' } + ]} + /> + } + /> + + + + + updateSettings({ + terminalJISYenToBackslash: !settings.terminalJISYenToBackslash + }) + } + /> + + ) : null}
diff --git a/src/renderer/src/components/settings/terminal-search.test.ts b/src/renderer/src/components/settings/terminal-search.test.ts index 634a074c4..d96500001 100644 --- a/src/renderer/src/components/settings/terminal-search.test.ts +++ b/src/renderer/src/components/settings/terminal-search.test.ts @@ -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 }) diff --git a/src/renderer/src/components/settings/terminal-search.ts b/src/renderer/src/components/settings/terminal-search.ts index 0a09e00ca..064e322d5 100644 --- a/src/renderer/src/components/settings/terminal-search.ts +++ b/src/renderer/src/components/settings/terminal-search.ts @@ -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] + : []) ] } diff --git a/src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts b/src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts new file mode 100644 index 000000000..4f3fcefcd --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-jis-yen-input.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalJisYenInput, type TerminalJisYenInputEvent } from './terminal-jis-yen-input' + +function event(overrides: Partial): 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-jis-yen-input.ts b/src/renderer/src/components/terminal-pane/terminal-jis-yen-input.ts new file mode 100644 index 000000000..1d4fb3ebb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-jis-yen-input.ts @@ -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 +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 37f7a3b76..5ea96aba4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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() }) }) diff --git a/src/shared/constants.ts b/src/shared/constants.ts index ac170c843..924ba5219 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -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. diff --git a/src/shared/types.ts b/src/shared/types.ts index 5bf9b10e9..9129b669f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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