diff --git a/src/tui/components/ColorMenu.tsx b/src/tui/components/ColorMenu.tsx index d6f5434..06b1080 100644 --- a/src/tui/components/ColorMenu.tsx +++ b/src/tui/components/ColorMenu.tsx @@ -15,6 +15,7 @@ import { getAvailableBackgroundColorsForUI, getAvailableColorsForUI } from '../../utils/colors'; +import { shouldInsertInput } from '../../utils/input-guards'; import { getWidget } from '../../utils/widgets'; import { ConfirmDialog } from './ConfirmDialog'; @@ -97,7 +98,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } else if (key.backspace || key.delete) { setHexInput(hexInput.slice(0, -1)); - } else if (input && hexInput.length < 6) { + } else if (shouldInsertInput(input, key) && hexInput.length < 6) { // Only accept hex characters (0-9, A-F, a-f) const upperInput = input.toUpperCase(); if (/^[0-9A-F]$/.test(upperInput)) { @@ -144,7 +145,7 @@ export const ColorMenu: React.FC = ({ widgets, lineIndex, settin } } else if (key.backspace || key.delete) { setAnsi256Input(ansi256Input.slice(0, -1)); - } else if (input && ansi256Input.length < 3) { + } else if (shouldInsertInput(input, key) && ansi256Input.length < 3) { // Only accept numeric characters (0-9) if (/^[0-9]$/.test(input)) { const newInput = ansi256Input + input; diff --git a/src/tui/components/GlobalOverridesMenu.tsx b/src/tui/components/GlobalOverridesMenu.tsx index d64f93c..ebc3a04 100644 --- a/src/tui/components/GlobalOverridesMenu.tsx +++ b/src/tui/components/GlobalOverridesMenu.tsx @@ -11,6 +11,7 @@ import { getChalkColor, getColorDisplayName } from '../../utils/colors'; +import { shouldInsertInput } from '../../utils/input-guards'; import { ConfirmDialog } from './ConfirmDialog'; @@ -57,7 +58,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin setPaddingInput(paddingInput.slice(0, -1)); } else if (key.delete) { // For simple text inputs without cursor, forward delete does nothing - } else if (input) { + } else if (shouldInsertInput(input, key)) { setPaddingInput(paddingInput + input); } } else if (editingSeparator) { @@ -86,7 +87,7 @@ export const GlobalOverridesMenu: React.FC = ({ settin setSeparatorInput(separatorInput.slice(0, -1)); } else if (key.delete) { // For simple text inputs without cursor, forward delete does nothing - } else if (input) { + } else if (shouldInsertInput(input, key)) { setSeparatorInput(separatorInput + input); } } else if (confirmingSeparator) { diff --git a/src/tui/components/PowerlineSeparatorEditor.tsx b/src/tui/components/PowerlineSeparatorEditor.tsx index 1648ade..92f4349 100644 --- a/src/tui/components/PowerlineSeparatorEditor.tsx +++ b/src/tui/components/PowerlineSeparatorEditor.tsx @@ -6,6 +6,7 @@ import { import React, { useState } from 'react'; import type { Settings } from '../../types/Settings'; +import { shouldInsertInput } from '../../utils/input-guards'; export type EditorMode = 'separator' | 'startCap' | 'endCap'; @@ -133,7 +134,7 @@ export const PowerlineSeparatorEditor: React.FC = } else if (key.backspace && cursorPos > 0) { setHexInput(hexInput.slice(0, cursorPos - 1) + hexInput.slice(cursorPos)); setCursorPos(cursorPos - 1); - } else if (input && /[0-9a-fA-F]/.test(input) && hexInput.length < 4) { + } else if (shouldInsertInput(input, key) && /[0-9a-fA-F]/.test(input) && hexInput.length < 4) { setHexInput(hexInput.slice(0, cursorPos) + input.toUpperCase() + hexInput.slice(cursorPos)); setCursorPos(cursorPos + 1); } diff --git a/src/tui/components/TerminalWidthMenu.tsx b/src/tui/components/TerminalWidthMenu.tsx index 893fc29..3134707 100644 --- a/src/tui/components/TerminalWidthMenu.tsx +++ b/src/tui/components/TerminalWidthMenu.tsx @@ -7,6 +7,7 @@ import React, { useState } from 'react'; import type { FlexMode } from '../../types/FlexMode'; import type { Settings } from '../../types/Settings'; +import { shouldInsertInput } from '../../utils/input-guards'; export interface TerminalWidthMenuProps { settings: Settings; @@ -58,7 +59,7 @@ export const TerminalWidthMenu: React.FC = ({ settings, setValidationError(null); } else if (key.delete) { // For simple number inputs, forward delete does nothing since there's no cursor position - } else if (input && /\d/.test(input)) { + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { const newValue = thresholdInput + input; if (newValue.length <= 2) { setThresholdInput(newValue); diff --git a/src/utils/__tests__/input-guards.test.ts b/src/utils/__tests__/input-guards.test.ts new file mode 100644 index 0000000..e3df8db --- /dev/null +++ b/src/utils/__tests__/input-guards.test.ts @@ -0,0 +1,31 @@ +import { + describe, + expect, + it +} from 'vitest'; + +import { shouldInsertInput } from '../input-guards'; + +describe('shouldInsertInput', () => { + it('allows regular printable input without modifiers', () => { + expect(shouldInsertInput('s', {})).toBe(true); + expect(shouldInsertInput('S', { shift: true })).toBe(true); + }); + + it('blocks ctrl chords', () => { + expect(shouldInsertInput('s', { ctrl: true })).toBe(false); + }); + + it('blocks meta chords', () => { + expect(shouldInsertInput('s', { meta: true })).toBe(false); + }); + + it('blocks tab-based input', () => { + expect(shouldInsertInput('\t', { tab: true })).toBe(false); + }); + + it('blocks control characters and allows unicode text', () => { + expect(shouldInsertInput('\u0013', {})).toBe(false); + expect(shouldInsertInput('🙂', {})).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/utils/input-guards.ts b/src/utils/input-guards.ts new file mode 100644 index 0000000..9da2272 --- /dev/null +++ b/src/utils/input-guards.ts @@ -0,0 +1,20 @@ +export interface InputKeyLike { + ctrl?: boolean; + meta?: boolean; + shift?: boolean; + tab?: boolean; +} + +const CONTROL_CHAR_REGEX = /[\u0000-\u001F\u007F]/u; + +export const shouldInsertInput = (input: string, key: InputKeyLike): boolean => { + if (!input) { + return false; + } + + if (key.ctrl || key.meta || key.tab) { + return false; + } + + return !CONTROL_CHAR_REGEX.test(input); +}; \ No newline at end of file diff --git a/src/widgets/CurrentWorkingDir.tsx b/src/widgets/CurrentWorkingDir.tsx index d3e459e..a2cb53e 100644 --- a/src/widgets/CurrentWorkingDir.tsx +++ b/src/widgets/CurrentWorkingDir.tsx @@ -15,6 +15,7 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { shouldInsertInput } from '../utils/input-guards'; export class CurrentWorkingDirWidget implements Widget { getDefaultColor(): string { return 'blue'; } @@ -200,7 +201,7 @@ const CurrentWorkingDirEditor: React.FC = ({ widget, onComple onCancel(); } else if (key.backspace) { setSegmentsInput(segmentsInput.slice(0, -1)); - } else if (input && /\d/.test(input) && !key.ctrl) { + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { setSegmentsInput(segmentsInput + input); } } diff --git a/src/widgets/CustomCommand.tsx b/src/widgets/CustomCommand.tsx index 8a8460d..cde2d5b 100644 --- a/src/widgets/CustomCommand.tsx +++ b/src/widgets/CustomCommand.tsx @@ -15,6 +15,7 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { shouldInsertInput } from '../utils/input-guards'; export class CustomCommandWidget implements Widget { getDefaultColor(): string { return 'white'; } @@ -160,7 +161,7 @@ const CustomCommandEditor: React.FC = ({ widget, onComplete, if (commandCursorPos < commandInput.length) { setCommandInput(commandInput.slice(0, commandCursorPos) + commandInput.slice(commandCursorPos + 1)); } - } else if (input) { + } else if (shouldInsertInput(input, key)) { setCommandInput(commandInput.slice(0, commandCursorPos) + input + commandInput.slice(commandCursorPos)); setCommandCursorPos(commandCursorPos + input.length); } @@ -178,7 +179,7 @@ const CustomCommandEditor: React.FC = ({ widget, onComplete, onCancel(); } else if (key.backspace) { setWidthInput(widthInput.slice(0, -1)); - } else if (input && /\d/.test(input)) { + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { setWidthInput(widthInput + input); } } else if (mode === 'timeout') { @@ -195,7 +196,7 @@ const CustomCommandEditor: React.FC = ({ widget, onComplete, onCancel(); } else if (key.backspace) { setTimeoutInput(timeoutInput.slice(0, -1)); - } else if (input && /\d/.test(input)) { + } else if (shouldInsertInput(input, key) && /\d/.test(input)) { setTimeoutInput(timeoutInput + input); } } diff --git a/src/widgets/CustomText.tsx b/src/widgets/CustomText.tsx index 0bda7ed..f28bf85 100644 --- a/src/widgets/CustomText.tsx +++ b/src/widgets/CustomText.tsx @@ -14,6 +14,7 @@ import type { WidgetEditorProps, WidgetItem } from '../types/Widget'; +import { shouldInsertInput } from '../utils/input-guards'; export class CustomTextWidget implements Widget { getDefaultColor(): string { return 'white'; } @@ -129,7 +130,7 @@ const CustomTextEditor: React.FC = ({ widget, onComplete, onC setText(text.slice(0, deleteFromIndex) + text.slice(deleteToIndex)); } } - } else if (input && !key.ctrl && !key.meta) { + } else if (shouldInsertInput(input, key)) { // Insert the input at cursor position const newText = text.slice(0, cursorPos) + input + text.slice(cursorPos); setText(newText);