diff --git a/apps/web/src/components/mobile-terminal-wrapper.tsx b/apps/web/src/components/mobile-terminal-wrapper.tsx index 15421a5..0e80f24 100644 --- a/apps/web/src/components/mobile-terminal-wrapper.tsx +++ b/apps/web/src/components/mobile-terminal-wrapper.tsx @@ -6,6 +6,7 @@ import { SpecialKeysPanel } from "./special-keys-panel"; import { useMobileViewport } from "../hooks/use-mobile-viewport"; import { useVirtualKeyboard } from "../hooks/use-virtual-keyboard"; import { useAutoHide } from "../hooks/use-auto-hide"; +import type { ModifierKey } from "../hooks/use-special-keys"; interface MobileTerminalWrapperProps { instanceId: string; @@ -26,6 +27,7 @@ export const MobileTerminalWrapper: React.FC = ({ const { isOpen: isKeyboardOpen, height: keyboardHeight } = useVirtualKeyboard(); const [showPanel, setShowPanel] = useState(false); + const [activeModifier, setActiveModifier] = useState(null); const [terminalRef, setTerminalRef] = useState<{ sendData: (data: string) => void; connectionStatus: "connecting" | "connected" | "disconnected" | "error"; @@ -84,6 +86,8 @@ export const MobileTerminalWrapper: React.FC = ({ instanceId={instanceId} onClose={onClose} isMobile={true} + activeModifier={activeModifier} + onModifierChange={setActiveModifier} onTerminalReady={handleTerminalReady} /> @@ -93,6 +97,8 @@ export const MobileTerminalWrapper: React.FC = ({ isVisible={!showPanel} onMoreClick={() => setShowPanel(true)} onKeepFocus={() => terminalRef?.focusInput()} + activeModifier={activeModifier} + onModifierChange={setActiveModifier} /> = ({ isOpen={showPanel} onClose={() => setShowPanel(false)} onKeepFocus={() => terminalRef?.focusInput()} + activeModifier={activeModifier} + onModifierChange={setActiveModifier} /> ); diff --git a/apps/web/src/components/special-keys-panel.tsx b/apps/web/src/components/special-keys-panel.tsx index ed604a0..d50a672 100644 --- a/apps/web/src/components/special-keys-panel.tsx +++ b/apps/web/src/components/special-keys-panel.tsx @@ -1,11 +1,13 @@ import React from "react"; -import { useSpecialKeys, type SpecialKey } from "../hooks/use-special-keys"; +import { getSequenceWithModifier, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys"; interface SpecialKeysPanelProps { onSend: (data: string) => void; isOpen: boolean; onClose: () => void; onKeepFocus?: () => void; + activeModifier: ModifierKey | null; + onModifierChange: (modifier: ModifierKey | null) => void; } const EXPANDED_KEYS: { key: SpecialKey; label: string }[] = [ @@ -38,14 +40,22 @@ export const SpecialKeysPanel: React.FC = ({ isOpen, onClose, onKeepFocus, + activeModifier, + onModifierChange, }) => { - const { sendKey, clearModifier } = useSpecialKeys({ onSend }); - if (!isOpen) return null; const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => { e.preventDefault(); - sendKey(key); + + const result = getSequenceWithModifier(key, activeModifier); + if (result) { + onSend(result.sequence); + if (result.clearModifier) { + onModifierChange(null); + } + } + onClose(); // Always refocus terminal after sending requestAnimationFrame(() => { @@ -55,7 +65,7 @@ export const SpecialKeysPanel: React.FC = ({ const handleOverlayPointerDown = (e: React.PointerEvent) => { e.preventDefault(); - clearModifier(); + onModifierChange(null); onClose(); requestAnimationFrame(() => { onKeepFocus?.(); diff --git a/apps/web/src/components/special-keys-strip.tsx b/apps/web/src/components/special-keys-strip.tsx index 6fed01c..9b4467e 100644 --- a/apps/web/src/components/special-keys-strip.tsx +++ b/apps/web/src/components/special-keys-strip.tsx @@ -1,11 +1,13 @@ import React from "react"; -import { useSpecialKeys, type SpecialKey, type ModifierKey } from "../hooks/use-special-keys"; +import { getSequenceWithModifier, type SpecialKey, type ModifierKey, KEY_SEQUENCES } from "../hooks/use-special-keys"; interface SpecialKeysStripProps { onSend: (data: string) => void; isVisible: boolean; onMoreClick?: () => void; onKeepFocus?: () => void; + activeModifier: ModifierKey | null; + onModifierChange: (modifier: ModifierKey | null) => void; } const PRIMARY_KEYS: { key: SpecialKey; label: string; isModifier?: boolean }[] = [ @@ -24,12 +26,29 @@ export const SpecialKeysStrip: React.FC = ({ isVisible, onMoreClick, onKeepFocus, + activeModifier, + onModifierChange, }) => { - const { sendKey, activeModifier } = useSpecialKeys({ onSend }); - const handlePointerDown = (e: React.PointerEvent, key: SpecialKey) => { e.preventDefault(); - sendKey(key); + + // Handle modifier keys (one-shot) + if (key === "ctrl" || key === "alt") { + onModifierChange(activeModifier === key ? null : key); + requestAnimationFrame(() => { + onKeepFocus?.(); + }); + return; + } + + const result = getSequenceWithModifier(key, activeModifier); + if (result) { + onSend(result.sequence); + if (result.clearModifier) { + onModifierChange(null); + } + } + // Always refocus terminal after sending requestAnimationFrame(() => { onKeepFocus?.(); diff --git a/apps/web/src/components/terminal.tsx b/apps/web/src/components/terminal.tsx index 5d14d55..654f6fb 100644 --- a/apps/web/src/components/terminal.tsx +++ b/apps/web/src/components/terminal.tsx @@ -4,10 +4,14 @@ import { FitAddon } from "xterm-addon-fit"; import { WebLinksAddon } from "xterm-addon-web-links"; import "xterm/css/xterm.css"; +import { applyModifierToChar, type ModifierKey } from "../hooks/use-special-keys"; + interface TerminalProps { instanceId: string; onClose?: () => void; isMobile?: boolean; + activeModifier?: ModifierKey | null; + onModifierChange?: (modifier: ModifierKey | null) => void; onTerminalReady?: ( sendData: (data: string) => void, connectionStatus: "connecting" | "connected" | "disconnected" | "error", @@ -25,6 +29,8 @@ export const TerminalComponent: React.FC = ({ instanceId, onClose, isMobile = false, + activeModifier, + onModifierChange, onTerminalReady, }) => { const terminalRef = useRef(null); @@ -39,6 +45,8 @@ export const TerminalComponent: React.FC = ({ "connecting" | "connected" | "disconnected" | "error" >("connecting"); const [error, setError] = useState(null); + const activeModifierRef = useRef(activeModifier); + activeModifierRef.current = activeModifier; const [fontSize, setFontSize] = useState(() => { if (typeof window === "undefined") return isMobile ? 16 : 14; const stored = localStorage.getItem(FONT_SIZE_KEY); @@ -161,9 +169,20 @@ export const TerminalComponent: React.FC = ({ // Handle terminal input term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(data); + if (ws.readyState !== WebSocket.OPEN) return; + + // Apply active modifier to single-character input + const modifier = activeModifierRef.current; + if (modifier && data.length === 1) { + const modified = applyModifierToChar(data, modifier); + if (modified) { + ws.send(modified); + onModifierChange?.(null); + return; + } } + + ws.send(data); }); // Handle resize with debounce diff --git a/apps/web/src/hooks/use-special-keys.ts b/apps/web/src/hooks/use-special-keys.ts index d15644c..55fe1f5 100644 --- a/apps/web/src/hooks/use-special-keys.ts +++ b/apps/web/src/hooks/use-special-keys.ts @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; + export type SpecialKey = | "escape" @@ -103,47 +103,38 @@ const MODIFIER_PREFIXES: Record void; +export function getSequenceWithModifier( + key: SpecialKey, + activeModifier: ModifierKey | null +): { sequence: string; clearModifier: boolean } | null { + // Handle modifier keys (one-shot) + if (key === "ctrl" || key === "alt") { + return null; // Modifiers don't send anything themselves + } + + const sequence = KEY_SEQUENCES[key]; + if (!sequence) return null; + + // Check if we have an active modifier and the key is a single character + if (activeModifier && sequence.length === 1) { + const char = sequence; + const mapping = MODIFIER_PREFIXES[char.toLowerCase()]; + if (mapping) { + return { sequence: mapping[activeModifier], clearModifier: true }; + } + } + + return { sequence, clearModifier: !!activeModifier }; } -export function useSpecialKeys({ onSend }: UseSpecialKeysOptions) { - const [activeModifier, setActiveModifier] = useState(null); - - const sendKey = useCallback( - (key: SpecialKey) => { - // Handle modifier keys (one-shot) - if (key === "ctrl" || key === "alt") { - // Toggle modifier - setActiveModifier((current) => (current === key ? null : key)); - return; - } - - const sequence = KEY_SEQUENCES[key]; - if (!sequence) return; - - // Check if we have an active modifier and the key is a single character - if (activeModifier && sequence.length === 1) { - const char = sequence; - const mapping = MODIFIER_PREFIXES[char.toLowerCase()]; - if (mapping) { - onSend(mapping[activeModifier]); - setActiveModifier(null); - return; - } - } - - onSend(sequence); - setActiveModifier(null); - }, - [onSend, activeModifier] - ); - - const clearModifier = useCallback(() => { - setActiveModifier(null); - }, []); - - return { sendKey, activeModifier, clearModifier }; +export function applyModifierToChar( + char: string, + modifier: ModifierKey +): string | null { + if (char.length !== 1) return null; + const mapping = MODIFIER_PREFIXES[char.toLowerCase()]; + if (!mapping) return null; + return mapping[modifier]; } export { KEY_SEQUENCES };