diff --git a/app/components/ui/Accordion.tsx b/app/components/ui/Accordion.tsx
index 0290fb1..0408e68 100644
--- a/app/components/ui/Accordion.tsx
+++ b/app/components/ui/Accordion.tsx
@@ -15,6 +15,7 @@ export function AccordionSection({
open: controlledOpen,
onToggle,
children,
+ containerClassName,
}: {
label: string;
icon?: React.ReactNode;
@@ -22,6 +23,7 @@ export function AccordionSection({
open?: boolean;
onToggle?: () => void;
children: React.ReactNode;
+ containerClassName?: string;
}) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
@@ -29,7 +31,7 @@ export function AccordionSection({
const muted = useThemeColor()("muted-foreground");
return (
-
+
{
if (isControlled) onToggle?.();
diff --git a/app/components/ui/Input.tsx b/app/components/ui/Input.tsx
index 1901f94..de773fd 100644
--- a/app/components/ui/Input.tsx
+++ b/app/components/ui/Input.tsx
@@ -30,10 +30,13 @@ export const Input = forwardRef(function Input(
// centered row — it must allow height and top-align its children. Single-line
// keeps the original fixed 40px row.
const layout = multiline
- ? "flex-row items-start gap-2 min-h-10 py-2 px-2.5"
- : "flex-row items-center gap-2 h-10 px-2.5";
+ ? "flex-row items-start gap-2 py-2"
+ : "flex-row items-center gap-2";
+ const inputLayout = multiline
+ ? "min-h-10 px-2.5"
+ : "h-10 px-2.5";
- return (
+ return (
@@ -42,7 +45,7 @@ export const Input = forwardRef(function Input(
ref={ref}
multiline={multiline}
placeholderTextColor={placeholderColor}
- className={`flex-1 text-sm text-foreground ${className ?? ""}`}
+ className={`${inputLayout} flex-1 text-sm text-foreground ${className ?? ""}`}
style={[
{ fontFamily: MONO_FONT, paddingVertical: 0 },
multiline ? { textAlignVertical: "top" } : null,
diff --git a/app/contexts/KeyboardCustomizationContext.tsx b/app/contexts/KeyboardCustomizationContext.tsx
index 2e5d89b..06c59d9 100644
--- a/app/contexts/KeyboardCustomizationContext.tsx
+++ b/app/contexts/KeyboardCustomizationContext.tsx
@@ -14,6 +14,10 @@ import {
KeyboardSettings,
} from "@/types/keyboard";
import { getPresetById } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions";
+import {
+ DEFAULT_KEY_REPEAT_INTERVAL,
+ DEFAULT_KEY_REPEAT_INITIAL_DELAY,
+} from "@/constants/keyboard-repeat-config";
const STORAGE_KEY = "keyboardCustomization";
const DEFAULT_PRESET_ID: PresetType = "default";
@@ -42,6 +46,8 @@ const getDefaultConfig = (): KeyboardCustomization => {
compactMode: false,
hapticFeedback: false,
showHints: true,
+ keyRepeatInterval: DEFAULT_KEY_REPEAT_INTERVAL,
+ keyRepeatInitialDelay: DEFAULT_KEY_REPEAT_INITIAL_DELAY,
},
};
};
diff --git a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx
index 09dc59c..db7b520 100644
--- a/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx
+++ b/app/tabs/sessions/terminal/keyboard/CustomKeyboard.tsx
@@ -6,6 +6,7 @@ import KeyboardKey from "./KeyboardKey";
import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext";
import { KeyConfig } from "@/types/keyboard";
import { BACKGROUNDS, BORDER_COLORS, TEXT_COLORS } from "@/app/constants/designTokens";
+import { isRepeatingKey } from "@/constants/keyboard-repeat-config";
interface CustomKeyboardProps {
terminalRef: React.RefObject;
@@ -181,6 +182,9 @@ export default function CustomKeyboard({
isActive={key.id === "shift" && shiftPressed}
keySize={config.settings.keySize}
hapticFeedback={config.settings.hapticFeedback}
+ keyRepeatEnabled={isRepeatingKey(key.id)}
+ keyRepeatInterval={config.settings.keyRepeatInterval}
+ keyRepeatInitialDelay={config.settings.keyRepeatInitialDelay}
/>
))}
diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx
index ce26c94..40fb653 100644
--- a/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx
+++ b/app/tabs/sessions/terminal/keyboard/KeyboardBar.tsx
@@ -7,6 +7,7 @@ import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationCo
import { KeyConfig } from "@/types/keyboard";
import { useOrientation } from "@/app/utils/orientation";
import { BACKGROUNDS, BORDER_COLORS, ACCENT } from "@/app/constants/designTokens";
+import { isRepeatingKey } from "@/constants/keyboard-repeat-config";
interface KeyboardBarProps {
terminalRef: React.RefObject;
@@ -146,6 +147,9 @@ export default function KeyboardBar({
}
keySize={config.settings.keySize}
hapticFeedback={config.settings.hapticFeedback}
+ keyRepeatEnabled={isRepeatingKey(keyConfig.id)}
+ keyRepeatInterval={config.settings.keyRepeatInterval}
+ keyRepeatInitialDelay={config.settings.keyRepeatInitialDelay}
/>
);
};
diff --git a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx
index e30c0ff..16f31f7 100644
--- a/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx
+++ b/app/tabs/sessions/terminal/keyboard/KeyboardKey.tsx
@@ -1,8 +1,13 @@
-import React from "react";
+import React, { useState, useRef, useEffect, useCallback } from "react";
import { TouchableOpacity, Text } from "react-native";
import * as Haptics from "expo-haptics";
import { KeySize } from "@/types/keyboard";
import { BACKGROUNDS, BORDER_COLORS, ACCENT } from "@/app/constants/designTokens";
+import {
+ DEFAULT_KEY_REPEAT_INTERVAL,
+ DEFAULT_KEY_REPEAT_INITIAL_DELAY,
+ useKeyRepeat,
+} from "@/constants/keyboard-repeat-config";
interface KeyboardKeyProps {
label: string;
@@ -14,6 +19,9 @@ interface KeyboardKeyProps {
keySize?: KeySize;
hapticFeedback?: boolean;
onLongPress?: () => void;
+ keyRepeatEnabled?: boolean;
+ keyRepeatInterval?: number;
+ keyRepeatInitialDelay?: number;
}
export default function KeyboardKey({
@@ -26,16 +34,43 @@ export default function KeyboardKey({
keySize = "medium",
hapticFeedback = false,
onLongPress,
+ keyRepeatEnabled = false,
+ keyRepeatInterval = DEFAULT_KEY_REPEAT_INTERVAL,
+ keyRepeatInitialDelay = DEFAULT_KEY_REPEAT_INITIAL_DELAY,
}: KeyboardKeyProps) {
- const handlePress = () => {
- if (hapticFeedback) {
+ const [isPressed, setIsPressed] = useState(false);
+ const onPressRef = useRef(onPress);
+ const hapticFeedbackRef = useRef(hapticFeedback);
+
+ useEffect(() => {
+ onPressRef.current = onPress;
+ }, [onPress]);
+
+ useEffect(() => {
+ hapticFeedbackRef.current = hapticFeedback;
+ }, [hapticFeedback]);
+
+ const handlePressIn = useCallback(() => {
+ setIsPressed(true);
+ }, []);
+
+ const handlePress = useCallback(() => {
+ if (hapticFeedbackRef.current) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
- onPress();
- };
+ onPressRef.current();
+ }, []);
+
+ useKeyRepeat(
+ isPressed, keyRepeatEnabled, handlePress, keyRepeatInterval, keyRepeatInitialDelay,
+ );
+
+ const handlePressOut = useCallback(() => {
+ setIsPressed(false);
+ }, []);
const handleLongPress = () => {
- if (hapticFeedback) {
+ if (hapticFeedbackRef.current) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}
if (onLongPress) {
@@ -84,6 +119,8 @@ export default function KeyboardKey({
borderColor,
borderRadius: 0,
}}
+ onPressIn={handlePressIn}
+ onPressOut={handlePressOut}
onPress={handlePress}
onLongPress={onLongPress ? handleLongPress : undefined}
activeOpacity={0.7}
diff --git a/app/tabs/settings/KeyboardCustomization.tsx b/app/tabs/settings/KeyboardCustomization.tsx
index 00f506e..b1c4d43 100644
--- a/app/tabs/settings/KeyboardCustomization.tsx
+++ b/app/tabs/settings/KeyboardCustomization.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useMemo } from "react";
+import React, { useState, useMemo, useEffect } from "react";
import { View, ScrollView, Pressable } from "react-native";
import { useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -10,11 +10,17 @@ import {
SlidersHorizontal,
} from "lucide-react-native";
import { useKeyboardCustomization } from "@/app/contexts/KeyboardCustomizationContext";
-import { PRESET_DEFINITIONS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions";
+import { ALL_KEYS, PRESET_DEFINITIONS } from "@/app/tabs/sessions/terminal/keyboard/KeyDefinitions";
import { PresetType, KeyConfig } from "@/types/keyboard";
import { toast } from "@/app/utils/toast";
-import { Text, Button, Label, FakeSwitch, Dialog } from "@/app/components/ui";
+import { Text, Button, Label, FakeSwitch, Dialog, Input, SettingRow } from "@/app/components/ui";
+import { AccordionSection } from "@/app/components/ui/Accordion";
import { useThemeColor } from "@/app/contexts/ThemeContext";
+import {
+ DEFAULT_KEY_REPEAT_INTERVAL,
+ DEFAULT_KEY_REPEAT_INITIAL_DELAY,
+ REPEATING_KEY_IDS,
+} from "@/constants/keyboard-repeat-config";
import KeySelector from "./components/KeySelector";
import UnifiedDraggableList, {
UnifiedListItem,
@@ -78,6 +84,15 @@ export default function KeyboardCustomization() {
const [activeTab, setActiveTab] = useState("presets");
const [showResetConfirm, setShowResetConfirm] = useState(false);
+
+ const [repeatIntervalText, setRepeatIntervalText] = useState(config.settings.keyRepeatInterval?.toString() || "");
+ const [initialDelayText, setInitialDelayText] = useState(config.settings.keyRepeatInitialDelay?.toString() || "");
+
+ useEffect(() => {
+ setRepeatIntervalText(config.settings.keyRepeatInterval?.toString() || "");
+ setInitialDelayText(config.settings.keyRepeatInitialDelay?.toString() || "");
+ }, [config.settings.keyRepeatInterval, config.settings.keyRepeatInitialDelay]);
+
const [resetType, setResetType] = useState<"all" | "topbar" | "fullkeyboard">(
"all",
);
@@ -565,7 +580,70 @@ export default function KeyboardCustomization() {
onChange={(v) => updateSettings({ showHints: v })}
/>
+
+
+
+
+ {/* Key Repeat */}
+
+
+
+
+
+ setInitialDelayText(t.replace(/\D/g, "")) }
+ onEndEditing={(e) => {
+ const val = parseInt(e.nativeEvent.text.replace(/\D/g, ""));
+ updateSettings({ keyRepeatInitialDelay: val || undefined });
+ setInitialDelayText(String(val || ""));
+ }}
+ />
+
+
+
+ setRepeatIntervalText(t.replace(/\D/g, "")) }
+ onEndEditing={(e) => {
+ const val = parseInt(e.nativeEvent.text.replace(/\D/g, ""));
+ updateSettings({ keyRepeatInterval: val || undefined });
+ setRepeatIntervalText(String(val || ""));
+ }}
+ />
+
+
+
+
+ These keys repeat continuously when held down
+ {Array.from(REPEATING_KEY_IDS).map((id) => {
+ const keyConfig = ALL_KEYS[id];
+ if (!keyConfig) return null;
+ return (
+
+
+ {keyConfig.label}
+
+
+ {keyConfig.description ?? id}
+
+
+ );
+ })}
+
{/* Reset */}
diff --git a/constants/keyboard-repeat-config.ts b/constants/keyboard-repeat-config.ts
new file mode 100644
index 0000000..86526d4
--- /dev/null
+++ b/constants/keyboard-repeat-config.ts
@@ -0,0 +1,100 @@
+/**
+ * Keyboard repeat configuration constants.
+ *
+ * These constants define the default behavior for keyboard key repeat
+ * (auto-repeat when keys are held down) functionality in the virtual keyboard.
+ *
+ * Standard behavior across operating systems:
+ * - Initial delay: 250-500ms (time before repeat starts)
+ * - Repeat interval: 50-200ms (time between repeated keystrokes)
+ *
+ * Optimization for touch devices:
+ * - Slightly longer initial delay (250ms) to allow natural key release
+ * - Slightly longer repeat interval (100ms) for comfortable touch interaction
+ */
+
+import { useRef, useEffect } from "react";
+
+/**
+ * Set of key IDs that should always auto-repeat on long-press,
+ * regardless of which keyboard (main grid or top bar) they appear on.
+ *
+ * Arrows, backspace, delete, space, and page keys repeat to support
+ * rapid navigation and text editing. Character keys are excluded so
+ * mobile long-press menus (e.g. é, ñ, ü) remain functional.
+ */
+export const REPEATING_KEY_IDS = new Set([
+ "arrowUp", "arrowDown", "arrowLeft", "arrowRight",
+ "backspace", "delete",
+ "space",
+ "pageUp", "pageDown",
+]);
+
+/**
+ * Check whether a key ID should auto-repeat on long-press.
+ * Used in both CustomKeyboard (main grid) and KeyboardBar (top bar).
+ */
+export const isRepeatingKey = (id: string): boolean =>
+ REPEATING_KEY_IDS.has(id);
+
+/**
+ * Default initial delay before key repeat starts (in milliseconds).
+ * This gives users time to release the key without triggering repeat.
+ * Standard OS keyboards typically use 250-500ms.
+ */
+export const DEFAULT_KEY_REPEAT_INITIAL_DELAY = 250;
+
+/**
+ * Default interval between repeated keystrokes (in milliseconds).
+ * For touch devices, 100ms (10 presses/sec) is more natural than typical desktop 50ms.
+ */
+export const DEFAULT_KEY_REPEAT_INTERVAL = 100;
+
+
+const clearTimers = (
+ timeoutRef: React.MutableRefObject | null>,
+ intervalRef: React.MutableRefObject | null>,
+) => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current);
+ timeoutRef.current = null;
+ }
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ intervalRef.current = null;
+ }
+};
+
+export const useKeyRepeat = (
+ isPressed: boolean,
+ enabled: boolean,
+ onPress: () => void,
+ repeatingInterval: number,
+ initialDelay: number,
+): void => {
+ const repeatTimeoutRef = useRef | null>(null);
+ const repeatIntervalRef = useRef | null>(null);
+ const onPressRef = useRef(onPress);
+
+ useEffect(() => {
+ onPressRef.current = onPress;
+ }, [onPress]);
+
+ useEffect(() => {
+ return () => clearTimers(repeatTimeoutRef, repeatIntervalRef);
+ }, []);
+
+ useEffect(() => {
+ if (isPressed && enabled) {
+ repeatTimeoutRef.current = setTimeout(() => {
+ repeatIntervalRef.current = setInterval(() => {
+ onPressRef.current();
+ }, repeatingInterval);
+ }, initialDelay);
+
+ return () => clearTimers(repeatTimeoutRef, repeatIntervalRef);
+ }
+
+ clearTimers(repeatTimeoutRef, repeatIntervalRef);
+ }, [isPressed, enabled, repeatingInterval, initialDelay]);
+};
diff --git a/types/keyboard.ts b/types/keyboard.ts
index 753bcb6..eed97e2 100644
--- a/types/keyboard.ts
+++ b/types/keyboard.ts
@@ -56,6 +56,8 @@ export interface KeyboardSettings {
compactMode: boolean;
hapticFeedback: boolean;
showHints: boolean;
+ keyRepeatInterval: number;
+ keyRepeatInitialDelay: number;
}
export interface KeyboardCustomization {