From 8be9e7c872fa2e5a9215047cad34c586c689bf6b Mon Sep 17 00:00:00 2001 From: xjdr-noumena Date: Wed, 24 Jun 2026 04:21:26 +0000 Subject: [PATCH] fix(modifiers): fail closed when modifiers-napi is the public stub modifiers-napi@0.0.1 on public npm is a reservation stub with no exports. The previous isModifierPressed destructured isModifierPressed: nativeIsModifierPressed from require('modifiers-napi') and called it unconditionally. When the stub is the resolved package, nativeIsModifierPressed is undefined and the call throws 'nativeIsModifierPressed is not a function' on every Enter keystroke at useTextInput.ts:263, which gates on env.terminal === 'Apple_Terminal' to work around Apple Terminal not supporting custom Shift+Enter keybindings. The throw bubbles up through the Ink input dispatch and blocks the Enter submit pipeline, so Terminal.app users cannot submit prompts interactively. Guard the destructure: if the export is not a function, report the modifier as not pressed. This disables best-effort Shift+Enter newline detection on Apple Terminal (a nicety) while restoring Enter submit. Returning false is the safe fallback since the path only ever asked whether Shift was held. The prewarm path is already wrapped in try/catch and is unaffected. Adds src/utils/modifiers.test.ts with two cases: - darwin + stub (no isModifierPressed export): returns false instead of throwing. This is the regression that blocked Enter on Apple Terminal. - non-darwin: returns false without consulting the stub. Refs: #44 --- src/utils/modifiers.test.ts | 61 +++++++++++++++++++++++++++++++++++++ src/utils/modifiers.ts | 14 +++++++-- 2 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 src/utils/modifiers.test.ts diff --git a/src/utils/modifiers.test.ts b/src/utils/modifiers.test.ts new file mode 100644 index 0000000..b17f783 --- /dev/null +++ b/src/utils/modifiers.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { platform } from 'os' + +const isDarwin = platform() === 'darwin' + +describe('modifiers — modifiers-napi stub fail-closed', () => { + let originalPlatform:PropertyDescriptor | undefined + let requireMock: ReturnType + + beforeEach(() => { + // Stub require('modifiers-napi') to return the reservation-stub shape + // (no exports) that's published on public npm as modifiers-napi@0.0.1. + requireMock = mock((moduleName: string) => { + if (moduleName !== 'modifiers-napi') { + throw new Error(`unexpected require: ${moduleName}`) + } + return {} as { isModifierPressed?: (m: string) => boolean } + }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ;(globalThis as any).require = requireMock + }) + + afterEach(() => { + mock.restore() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (globalThis as any).require + }) + + test('isModifierPressed returns false instead of throwing when the native export is missing', async () => { + const originalPlatformDesc = Object.getOwnPropertyDescriptor(process, 'platform') + if (!isDarwin) { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }) + } + try { + const { isModifierPressed } = await import('./modifiers.js') + // Must not throw TypeError. Stub has no isModifierPressed export, so the + // guard path runs and yields false (modifier reported as not pressed). + expect(() => isModifierPressed('shift')).not.toThrow() + expect(isModifierPressed('shift')).toBe(false) + expect(isModifierPressed('command')).toBe(false) + } finally { + if (originalPlatformDesc) { + Object.defineProperty(process, 'platform', originalPlatformDesc) + } + } + }) + + test('isModifierPressed returns false on non-darwin platforms regardless of stub', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }) + try { + const { isModifierPressed } = await import('./modifiers.js') + expect(isModifierPressed('shift')).toBe(false) + // The stub require() must not have been consulted on non-darwin. + expect(requireMock).toHaveBeenCalledTimes(0) + } finally { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform) + } + } + }) +}) \ No newline at end of file diff --git a/src/utils/modifiers.ts b/src/utils/modifiers.ts index 08bde4b..6f5579a 100644 --- a/src/utils/modifiers.ts +++ b/src/utils/modifiers.ts @@ -23,14 +23,24 @@ export function prewarmModifiers(): void { /** * Check if a specific modifier key is currently pressed (synchronous). + * + * `modifiers-napi` is a reserved stub (`0.0.1`, no exports) on public npm. When + * the native module is unavailable we fail closed: report the modifier as not + * pressed. That disables Apple_Terminal's best-effort Shift+Enter newline + * detection (a nicety) without breaking Enter submit (which the stub otherwise + * crashes on every keystroke). */ export function isModifierPressed(modifier: ModifierKey): boolean { if (process.platform !== 'darwin') { return false } - // Dynamic import to avoid loading native module at top level const { isModifierPressed: nativeIsModifierPressed } = // eslint-disable-next-line @typescript-eslint/no-require-imports - require('modifiers-napi') as { isModifierPressed: (m: string) => boolean } + require('modifiers-napi') as { + isModifierPressed?: (m: string) => boolean + } + if (typeof nativeIsModifierPressed !== 'function') { + return false + } return nativeIsModifierPressed(modifier) }