diff --git a/src/components/shell/hooks/useShellRuntime.ts b/src/components/shell/hooks/useShellRuntime.ts index 0af6d2f5..ecef3aa3 100644 --- a/src/components/shell/hooks/useShellRuntime.ts +++ b/src/components/shell/hooks/useShellRuntime.ts @@ -149,6 +149,8 @@ export function useShellRuntime({ return { terminalContainerRef, + terminalRef, + wsRef, isConnected, isInitialized, isConnecting, diff --git a/src/components/shell/types/types.ts b/src/components/shell/types/types.ts index b681ad7c..e4bed994 100644 --- a/src/components/shell/types/types.ts +++ b/src/components/shell/types/types.ts @@ -61,6 +61,8 @@ export type ShellSharedRefs = { export type UseShellRuntimeResult = { terminalContainerRef: RefObject; + terminalRef: MutableRefObject; + wsRef: MutableRefObject; isConnected: boolean; isInitialized: boolean; isConnecting: boolean; diff --git a/src/components/shell/view/Shell.tsx b/src/components/shell/view/Shell.tsx index 4fa563b7..822397c9 100644 --- a/src/components/shell/view/Shell.tsx +++ b/src/components/shell/view/Shell.tsx @@ -9,6 +9,7 @@ import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay'; import ShellEmptyState from './subcomponents/ShellEmptyState'; import ShellHeader from './subcomponents/ShellHeader'; import ShellMinimalView from './subcomponents/ShellMinimalView'; +import TerminalShortcutsPanel from './subcomponents/TerminalShortcutsPanel'; type ShellProps = { selectedProject?: Project | null; @@ -39,6 +40,8 @@ export default function Shell({ const { terminalContainerRef, + terminalRef, + wsRef, isConnected, isInitialized, isConnecting, @@ -157,6 +160,12 @@ export default function Shell({ /> )} + + ); } diff --git a/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx new file mode 100644 index 00000000..3af78c6f --- /dev/null +++ b/src/components/shell/view/subcomponents/TerminalShortcutsPanel.tsx @@ -0,0 +1,170 @@ +import { type MutableRefObject, useState, useCallback, useEffect, useRef } from 'react'; +import { + ChevronLeft, + ChevronRight, + Keyboard, + ArrowDownToLine, +} from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { Terminal } from '@xterm/xterm'; +import { sendSocketMessage } from '../../utils/socket'; + +const SHORTCUTS = [ + { id: 'escape', labelKey: 'escape', sequence: '\x1b', hint: 'Esc' }, + { id: 'tab', labelKey: 'tab', sequence: '\t', hint: 'Tab' }, + { id: 'shift-tab', labelKey: 'shiftTab', sequence: '\x1b[Z', hint: '\u21e7Tab' }, + { id: 'arrow-up', labelKey: 'arrowUp', sequence: '\x1b[A', hint: '\u2191' }, + { id: 'arrow-down', labelKey: 'arrowDown', sequence: '\x1b[B', hint: '\u2193' }, +] as const; + +type TerminalShortcutsPanelProps = { + wsRef: MutableRefObject; + terminalRef: MutableRefObject; + isConnected: boolean; +}; + +const preventFocusSteal = (e: React.PointerEvent) => e.preventDefault(); + +export default function TerminalShortcutsPanel({ + wsRef, + terminalRef, + isConnected, +}: TerminalShortcutsPanelProps) { + const { t } = useTranslation('settings'); + const [isOpen, setIsOpen] = useState(false); + const closeTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + }; + }, []); + + const handleToggle = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const handleShortcutAction = useCallback((action: () => void) => { + action(); + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + if (closeTimeoutRef.current) { + clearTimeout(closeTimeoutRef.current); + } + closeTimeoutRef.current = setTimeout(() => setIsOpen(false), 50); + }, []); + + const sendInput = useCallback( + (data: string) => { + sendSocketMessage(wsRef.current, { type: 'input', data }); + }, + [wsRef], + ); + + const scrollToBottom = useCallback(() => { + terminalRef.current?.scrollToBottom(); + }, [terminalRef]); + + return ( + <> + {/* Pull Tab */} + + + {/* Panel */} +
+
+ {/* Header */} +
+

+ + {t('terminalShortcuts.title')} +

+
+ + {/* Content — conditionally rendered so buttons remount with clean CSS states */} + {isOpen && ( +
+ {/* Shortcut Keys */} +
+

+ {t('terminalShortcuts.sectionKeys')} +

+ {SHORTCUTS.map((shortcut) => ( + + ))} +
+ + {/* Navigation */} +
+

+ {t('terminalShortcuts.sectionNavigation')} +

+ +
+
+ )} +
+
+ + {/* Backdrop */} + {isOpen && ( +
+ )} + + ); +} diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 617008a2..2c6a99e1 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -83,6 +83,21 @@ } } }, + "terminalShortcuts": { + "title": "Terminal Shortcuts", + "sectionKeys": "Keys", + "sectionNavigation": "Navigation", + "escape": "Escape", + "tab": "Tab", + "shiftTab": "Shift+Tab", + "arrowUp": "Arrow Up", + "arrowDown": "Arrow Down", + "scrollDown": "Scroll Down", + "handle": { + "closePanel": "Close shortcuts panel", + "openPanel": "Open shortcuts panel" + } + }, "mainTabs": { "label": "Settings", "agents": "Agents", diff --git a/src/i18n/locales/ja/settings.json b/src/i18n/locales/ja/settings.json index 05b94408..4fd82ec8 100644 --- a/src/i18n/locales/ja/settings.json +++ b/src/i18n/locales/ja/settings.json @@ -83,6 +83,21 @@ } } }, + "terminalShortcuts": { + "title": "ターミナルショートカット", + "sectionKeys": "キー", + "sectionNavigation": "ナビゲーション", + "escape": "Escape", + "tab": "Tab", + "shiftTab": "Shift+Tab", + "arrowUp": "上矢印", + "arrowDown": "下矢印", + "scrollDown": "下にスクロール", + "handle": { + "closePanel": "ショートカットパネルを閉じる", + "openPanel": "ショートカットパネルを開く" + } + }, "mainTabs": { "label": "設定", "agents": "エージェント", diff --git a/src/i18n/locales/ko/settings.json b/src/i18n/locales/ko/settings.json index bc1074c3..f452291f 100644 --- a/src/i18n/locales/ko/settings.json +++ b/src/i18n/locales/ko/settings.json @@ -83,6 +83,21 @@ } } }, + "terminalShortcuts": { + "title": "터미널 단축키", + "sectionKeys": "키", + "sectionNavigation": "탐색", + "escape": "Escape", + "tab": "Tab", + "shiftTab": "Shift+Tab", + "arrowUp": "위쪽 화살표", + "arrowDown": "아래쪽 화살표", + "scrollDown": "아래로 스크롤", + "handle": { + "closePanel": "단축키 패널 닫기", + "openPanel": "단축키 패널 열기" + } + }, "mainTabs": { "label": "설정", "agents": "에이전트", diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index fc910506..cdfb5497 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -83,6 +83,21 @@ } } }, + "terminalShortcuts": { + "title": "终端快捷键", + "sectionKeys": "按键", + "sectionNavigation": "导航", + "escape": "Escape", + "tab": "Tab", + "shiftTab": "Shift+Tab", + "arrowUp": "上箭头", + "arrowDown": "下箭头", + "scrollDown": "滚动到底部", + "handle": { + "closePanel": "关闭快捷键面板", + "openPanel": "打开快捷键面板" + } + }, "mainTabs": { "label": "设置", "agents": "智能体",