From 7ddd9f8be103c7fa7f51462a0033549a0d25db3f Mon Sep 17 00:00:00 2001 From: SuperComboGamer Date: Sun, 21 Dec 2025 15:33:43 -0500 Subject: [PATCH] feat: enhance terminal navigation and session management - Implemented spatial navigation between terminal panes using directional shortcuts (Ctrl+Alt+Arrow keys). - Improved session handling by ensuring stale sessions are automatically removed when the server indicates they are invalid. - Added customizable keyboard shortcuts for terminal actions and enhanced search functionality with dedicated highlighting colors. - Updated terminal themes to include search highlighting colors for better visibility during searches. - Refactored terminal layout saving logic to prevent incomplete state saves during project restoration. --- .../ui/src/components/views/terminal-view.tsx | 163 ++++++++++++--- .../views/terminal-view/terminal-panel.tsx | 197 +++++++++++++++--- apps/ui/src/config/terminal-themes.ts | 85 ++++++++ apps/ui/src/hooks/use-keyboard-shortcuts.ts | 96 +++++++++ apps/ui/src/store/app-store.ts | 33 +++ docs/terminal.md | 26 ++- 6 files changed, 536 insertions(+), 64 deletions(-) diff --git a/apps/ui/src/components/views/terminal-view.tsx b/apps/ui/src/components/views/terminal-view.tsx index 5f2b1a42..36e0054d 100644 --- a/apps/ui/src/components/views/terminal-view.tsx +++ b/apps/ui/src/components/views/terminal-view.tsx @@ -447,7 +447,8 @@ export function TerminalView() { // The path check in restoreLayout will handle this // Save layout for previous project (if there was one and has terminals) - if (prevPath && terminalState.tabs.length > 0) { + // BUT don't save if we were mid-restore for that project (would save incomplete state) + if (prevPath && terminalState.tabs.length > 0 && restoringProjectPathRef.current !== prevPath) { saveTerminalLayout(prevPath); } @@ -460,19 +461,25 @@ export function TerminalView() { return; } + // ALWAYS clear existing terminals when switching projects + // This is critical - prevents old project's terminals from "bleeding" into new project + clearTerminalState(); + // Check for saved layout for this project const savedLayout = getPersistedTerminalLayout(currentPath); - if (savedLayout && savedLayout.tabs.length > 0) { - // Restore the saved layout - try to reconnect to existing sessions - // Track which project we're restoring to detect stale restores - restoringProjectPathRef.current = currentPath; + // If no saved layout or no tabs, we're done - terminal starts fresh for this project + if (!savedLayout || savedLayout.tabs.length === 0) { + console.log("[Terminal] No saved layout for project, starting fresh"); + return; + } - // Clear existing terminals first (only client state, sessions stay on server) - clearTerminalState(); + // Restore the saved layout - try to reconnect to existing sessions + // Track which project we're restoring to detect stale restores + restoringProjectPathRef.current = currentPath; - // Create terminals and build layout - try to reconnect or create new - const restoreLayout = async () => { + // Create terminals and build layout - try to reconnect or create new + const restoreLayout = async () => { // Check if we're still restoring the same project (user may have switched) if (restoringProjectPathRef.current !== currentPath) { console.log("[Terminal] Restore cancelled - project changed"); @@ -643,21 +650,29 @@ export function TerminalView() { }; restoreLayout(); - } }, [currentProject?.path, saveTerminalLayout, getPersistedTerminalLayout, clearTerminalState, addTerminalTab, serverUrl]); // Save terminal layout whenever it changes (debounced to prevent excessive writes) // Also save when tabs become empty so closed terminals stay closed on refresh const saveLayoutTimeoutRef = useRef(null); + const pendingSavePathRef = useRef(null); useEffect(() => { + const projectPath = currentProject?.path; // Don't save while restoring this project's layout - if (currentProject?.path && restoringProjectPathRef.current !== currentProject.path) { + if (projectPath && restoringProjectPathRef.current !== projectPath) { // Debounce saves to prevent excessive localStorage writes during rapid changes if (saveLayoutTimeoutRef.current) { clearTimeout(saveLayoutTimeoutRef.current); } + // Capture the project path at schedule time so we save to the correct project + // even if user switches projects before the timeout fires + pendingSavePathRef.current = projectPath; saveLayoutTimeoutRef.current = setTimeout(() => { - saveTerminalLayout(currentProject.path); + // Only save if we're still on the same project + if (pendingSavePathRef.current === projectPath) { + saveTerminalLayout(projectPath); + } + pendingSavePathRef.current = null; saveLayoutTimeoutRef.current = null; }, 500); // 500ms debounce } @@ -949,28 +964,93 @@ export function TerminalView() { }); }, []); - // Navigate between terminal panes with Ctrl+Alt+Arrow keys - const navigateToTerminal = useCallback((direction: "next" | "prev") => { + // Navigate between terminal panes with directional awareness + // Arrow keys navigate in the actual spatial direction within the layout + const navigateToTerminal = useCallback((direction: "up" | "down" | "left" | "right") => { if (!activeTab?.layout) return; - const terminalIds = getTerminalIds(activeTab.layout); - if (terminalIds.length <= 1) return; - - const currentIndex = terminalIds.indexOf(terminalState.activeSessionId || ""); - if (currentIndex === -1) { + const currentSessionId = terminalState.activeSessionId; + if (!currentSessionId) { // If no terminal is active, focus the first one - setActiveTerminalSession(terminalIds[0]); + const terminalIds = getTerminalIds(activeTab.layout); + if (terminalIds.length > 0) { + setActiveTerminalSession(terminalIds[0]); + } return; } - let newIndex: number; - if (direction === "next") { - newIndex = (currentIndex + 1) % terminalIds.length; - } else { - newIndex = (currentIndex - 1 + terminalIds.length) % terminalIds.length; - } + // Find the terminal in the given direction + // The algorithm traverses the layout tree to find spatially adjacent terminals + const findTerminalInDirection = ( + layout: TerminalPanelContent, + targetId: string, + dir: "up" | "down" | "left" | "right" + ): string | null => { + // Helper to get all terminal IDs from a layout subtree + const getAllTerminals = (node: TerminalPanelContent): string[] => { + if (node.type === "terminal") return [node.sessionId]; + return node.panels.flatMap(getAllTerminals); + }; - setActiveTerminalSession(terminalIds[newIndex]); + // Helper to find terminal and its path in the tree + type PathEntry = { node: TerminalPanelContent; index: number; direction: "horizontal" | "vertical" }; + const findPath = ( + node: TerminalPanelContent, + target: string, + path: PathEntry[] = [] + ): PathEntry[] | null => { + if (node.type === "terminal") { + return node.sessionId === target ? path : null; + } + for (let i = 0; i < node.panels.length; i++) { + const result = findPath(node.panels[i], target, [ + ...path, + { node, index: i, direction: node.direction }, + ]); + if (result) return result; + } + return null; + }; + + const path = findPath(layout, targetId); + if (!path || path.length === 0) return null; + + // Determine which split direction we need based on arrow direction + // left/right navigation works in "horizontal" splits (panels side by side) + // up/down navigation works in "vertical" splits (panels stacked) + const neededDirection = dir === "left" || dir === "right" ? "horizontal" : "vertical"; + const goingForward = dir === "right" || dir === "down"; + + // Walk up the path to find a split in the right direction with an adjacent panel + for (let i = path.length - 1; i >= 0; i--) { + const entry = path[i]; + if (entry.direction === neededDirection) { + const siblings = entry.node.type === "split" ? entry.node.panels : []; + const nextIndex = goingForward ? entry.index + 1 : entry.index - 1; + + if (nextIndex >= 0 && nextIndex < siblings.length) { + // Found an adjacent panel in the right direction + const adjacentPanel = siblings[nextIndex]; + const adjacentTerminals = getAllTerminals(adjacentPanel); + + if (adjacentTerminals.length > 0) { + // When moving forward (right/down), pick the first terminal in that subtree + // When moving backward (left/up), pick the last terminal in that subtree + return goingForward + ? adjacentTerminals[0] + : adjacentTerminals[adjacentTerminals.length - 1]; + } + } + } + } + + return null; + }; + + const nextTerminal = findTerminalInDirection(activeTab.layout, currentSessionId, direction); + if (nextTerminal) { + setActiveTerminalSession(nextTerminal); + } }, [activeTab?.layout, terminalState.activeSessionId, setActiveTerminalSession]); // Handle global keyboard shortcuts for pane navigation @@ -978,12 +1058,18 @@ export function TerminalView() { const handleKeyDown = (e: KeyboardEvent) => { // Ctrl+Alt+Arrow (or Cmd+Alt+Arrow on Mac) for pane navigation if ((e.ctrlKey || e.metaKey) && e.altKey && !e.shiftKey) { - if (e.key === "ArrowRight" || e.key === "ArrowDown") { + if (e.key === "ArrowRight") { e.preventDefault(); - navigateToTerminal("next"); - } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + navigateToTerminal("right"); + } else if (e.key === "ArrowLeft") { e.preventDefault(); - navigateToTerminal("prev"); + navigateToTerminal("left"); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + navigateToTerminal("down"); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + navigateToTerminal("up"); } } }; @@ -1019,6 +1105,16 @@ export function TerminalView() { onSplitHorizontal={() => createTerminal("horizontal", content.sessionId)} onSplitVertical={() => createTerminal("vertical", content.sessionId)} onNewTab={createTerminalInNewTab} + onNavigateUp={() => navigateToTerminal("up")} + onNavigateDown={() => navigateToTerminal("down")} + onNavigateLeft={() => navigateToTerminal("left")} + onNavigateRight={() => navigateToTerminal("right")} + onSessionInvalid={() => { + // Auto-remove stale session when server says it doesn't exist + // This handles cases like server restart where sessions are lost + console.log(`[Terminal] Session ${content.sessionId} is invalid, removing from layout`); + killTerminal(content.sessionId); + }} isDragging={activeDragId === content.sessionId} isDropTarget={activeDragId !== null && activeDragId !== content.sessionId} fontSize={terminalFontSize} @@ -1384,6 +1480,11 @@ export function TerminalView() { onSplitHorizontal={() => createTerminal("horizontal", terminalState.maximizedSessionId!)} onSplitVertical={() => createTerminal("vertical", terminalState.maximizedSessionId!)} onNewTab={createTerminalInNewTab} + onSessionInvalid={() => { + const sessionId = terminalState.maximizedSessionId!; + console.log(`[Terminal] Maximized session ${sessionId} is invalid, removing from layout`); + killTerminal(sessionId); + }} isDragging={false} isDropTarget={false} fontSize={findTerminalFontSize(terminalState.maximizedSessionId)} diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 4a10cc11..95520ef7 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -30,8 +30,9 @@ import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { useDraggable, useDroppable } from "@dnd-kit/core"; -import { useAppStore } from "@/store/app-store"; +import { useAppStore, DEFAULT_KEYBOARD_SHORTCUTS, type KeyboardShortcuts } from "@/store/app-store"; import { useShallow } from "zustand/react/shallow"; +import { matchesShortcutWithCode } from "@/hooks/use-keyboard-shortcuts"; import { getTerminalTheme, TERMINAL_FONT_OPTIONS, DEFAULT_TERMINAL_FONT } from "@/config/terminal-themes"; import { toast } from "sonner"; import { getElectronAPI } from "@/lib/electron"; @@ -62,6 +63,11 @@ interface TerminalPanelProps { onSplitHorizontal: () => void; onSplitVertical: () => void; onNewTab?: () => void; + onNavigateUp?: () => void; // Navigate to terminal pane above + onNavigateDown?: () => void; // Navigate to terminal pane below + onNavigateLeft?: () => void; // Navigate to terminal pane on the left + onNavigateRight?: () => void; // Navigate to terminal pane on the right + onSessionInvalid?: () => void; // Called when session is no longer valid on server (e.g., server restarted) isDragging?: boolean; isDropTarget?: boolean; fontSize: number; @@ -87,6 +93,11 @@ export function TerminalPanel({ onSplitHorizontal, onSplitVertical, onNewTab, + onNavigateUp, + onNavigateDown, + onNavigateLeft, + onNavigateRight, + onSessionInvalid, isDragging = false, isDropTarget = false, fontSize, @@ -177,6 +188,15 @@ export function TerminalPanel({ const getEffectiveTheme = useAppStore((state) => state.getEffectiveTheme); const effectiveTheme = getEffectiveTheme(); + // Get keyboard shortcuts from store - merged with defaults + const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); + const mergedShortcuts: KeyboardShortcuts = { + ...DEFAULT_KEYBOARD_SHORTCUTS, + ...keyboardShortcuts, + }; + const shortcutsRef = useRef(mergedShortcuts); + shortcutsRef.current = mergedShortcuts; + // Track system dark mode preference for "system" theme const [systemIsDark, setSystemIsDark] = useState(() => { if (typeof window !== "undefined") { @@ -212,6 +232,16 @@ export function TerminalPanel({ onSplitVerticalRef.current = onSplitVertical; const onNewTabRef = useRef(onNewTab); onNewTabRef.current = onNewTab; + const onNavigateUpRef = useRef(onNavigateUp); + onNavigateUpRef.current = onNavigateUp; + const onNavigateDownRef = useRef(onNavigateDown); + onNavigateDownRef.current = onNavigateDown; + const onNavigateLeftRef = useRef(onNavigateLeft); + onNavigateLeftRef.current = onNavigateLeft; + const onNavigateRightRef = useRef(onNavigateRight); + onNavigateRightRef.current = onNavigateRight; + const onSessionInvalidRef = useRef(onSessionInvalid); + onSessionInvalidRef.current = onSessionInvalid; const fontSizeRef = useRef(fontSize); fontSizeRef.current = fontSize; const themeRef = useRef(resolvedTheme); @@ -348,18 +378,33 @@ export function TerminalPanel({ xtermRef.current?.clear(); }, []); + // Get theme colors for search highlighting + const terminalTheme = getTerminalTheme(effectiveTheme); + const searchOptions = { + caseSensitive: false, + regex: false, + decorations: { + matchBackground: terminalTheme.searchMatchBackground, + matchBorder: terminalTheme.searchMatchBorder, + matchOverviewRuler: terminalTheme.searchMatchBorder, + activeMatchBackground: terminalTheme.searchActiveMatchBackground, + activeMatchBorder: terminalTheme.searchActiveMatchBorder, + activeMatchColorOverviewRuler: terminalTheme.searchActiveMatchBorder, + }, + }; + // Search functions const searchNext = useCallback(() => { if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findNext(searchQuery, { caseSensitive: false, regex: false }); + searchAddonRef.current.findNext(searchQuery, searchOptions); } - }, [searchQuery]); + }, [searchQuery, searchOptions]); const searchPrevious = useCallback(() => { if (searchAddonRef.current && searchQuery) { - searchAddonRef.current.findPrevious(searchQuery, { caseSensitive: false, regex: false }); + searchAddonRef.current.findPrevious(searchQuery, searchOptions); } - }, [searchQuery]); + }, [searchQuery, searchOptions]); const closeSearch = useCallback(() => { setShowSearch(false); @@ -369,6 +414,32 @@ export function TerminalPanel({ xtermRef.current?.focus(); }, []); + // Handle pane navigation keyboard shortcuts at container level (capture phase) + // This ensures we intercept before xterm can process the event + const handleContainerKeyDownCapture = useCallback((event: React.KeyboardEvent) => { + // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally + if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { + const code = event.nativeEvent.code; + if (code === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + onNavigateRight?.(); + } else if (code === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + onNavigateLeft?.(); + } else if (code === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + onNavigateDown?.(); + } else if (code === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + onNavigateUp?.(); + } + } + }, [onNavigateUp, onNavigateDown, onNavigateLeft, onNavigateRight]); + // Scroll to bottom of terminal const scrollToBottom = useCallback(() => { if (xtermRef.current) { @@ -482,8 +553,21 @@ export function TerminalPanel({ terminal.loadAddon(searchAddon); searchAddonRef.current = searchAddon; - // Create web links addon for clickable URLs - const webLinksAddon = new WebLinksAddon(); + // Create web links addon for clickable URLs with custom handler for Electron + const webLinksAddon = new WebLinksAddon((_event: MouseEvent, uri: string) => { + // Use Electron API to open external links in system browser + const api = getElectronAPI(); + if (api?.openExternalLink) { + api.openExternalLink(uri).catch((error) => { + console.error("[Terminal] Failed to open URL:", error); + // Fallback to window.open if Electron API fails + window.open(uri, "_blank", "noopener,noreferrer"); + }); + } else { + // Web fallback + window.open(uri, "_blank", "noopener,noreferrer"); + } + }); terminal.loadAddon(webLinksAddon); // Open terminal @@ -661,15 +745,45 @@ export function TerminalPanel({ // Only intercept keydown events if (event.type !== 'keydown') return true; + // Use event.code for keyboard-layout-independent key detection + const code = event.code; + + // Ctrl+Alt+Arrow / Cmd+Alt+Arrow - Navigate between panes directionally + // Handle this FIRST before any other checks to prevent xterm from capturing it + // Use explicit check for both Ctrl and Meta to work on all platforms + if ((event.ctrlKey || event.metaKey) && event.altKey && !event.shiftKey) { + if (code === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + onNavigateRightRef.current?.(); + return false; + } else if (code === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + onNavigateLeftRef.current?.(); + return false; + } else if (code === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + onNavigateDownRef.current?.(); + return false; + } else if (code === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + onNavigateUpRef.current?.(); + return false; + } + } + // Check cooldown to prevent rapid terminal creation const now = Date.now(); const canTrigger = now - lastShortcutTimeRef.current > SHORTCUT_COOLDOWN_MS; - // Use event.code for keyboard-layout-independent key detection - const code = event.code; + // Get current shortcuts from ref (allows customization) + const shortcuts = shortcutsRef.current; - // Alt+D - Split right - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyD') { + // Split right (default: Alt+D) + if (matchesShortcutWithCode(event, shortcuts.splitTerminalRight)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -678,8 +792,8 @@ export function TerminalPanel({ return false; } - // Alt+S - Split down - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyS') { + // Split down (default: Alt+S) + if (matchesShortcutWithCode(event, shortcuts.splitTerminalDown)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -688,8 +802,8 @@ export function TerminalPanel({ return false; } - // Alt+W - Close terminal - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyW') { + // Close terminal (default: Alt+W) + if (matchesShortcutWithCode(event, shortcuts.closeTerminal)) { event.preventDefault(); if (canTrigger) { lastShortcutTimeRef.current = now; @@ -698,8 +812,8 @@ export function TerminalPanel({ return false; } - // Alt+T - New terminal tab - if (event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && code === 'KeyT') { + // New terminal tab (default: Alt+T) + if (matchesShortcutWithCode(event, shortcuts.newTerminalTab)) { event.preventDefault(); if (canTrigger && onNewTabRef.current) { lastShortcutTimeRef.current = now; @@ -843,11 +957,20 @@ export function TerminalPanel({ hasRunInitialCommandRef.current = true; } break; - case "connected": + case "connected": { console.log(`[Terminal] Session connected: ${msg.shell} in ${msg.cwd}`); + // Detect shell type from path + const shellPath = (msg.shell || "").toLowerCase(); + // Windows shells use backslash paths and include powershell/pwsh/cmd + const isWindowsShell = shellPath.includes("\\") || + shellPath.includes("powershell") || + shellPath.includes("pwsh") || + shellPath.includes("cmd.exe"); + const isPowerShell = shellPath.includes("powershell") || shellPath.includes("pwsh"); + if (msg.shell) { - // Extract shell name from path (e.g., "/bin/bash" -> "bash") - const name = msg.shell.split("/").pop() || msg.shell; + // Extract shell name from path (e.g., "/bin/bash" -> "bash", "C:\...\powershell.exe" -> "powershell.exe") + const name = msg.shell.split(/[/\\]/).pop() || msg.shell; setShellName(name); } // Run initial command if specified and not already run @@ -858,15 +981,22 @@ export function TerminalPanel({ ws.readyState === WebSocket.OPEN ) { hasRunInitialCommandRef.current = true; - // Small delay to let the shell prompt appear + // Use appropriate line ending for the shell type + // Windows shells (PowerShell, cmd) expect \r\n, Unix shells expect \n + const lineEnding = isWindowsShell ? "\r\n" : "\n"; + // PowerShell takes longer to initialize (profile loading, etc.) + // Use 500ms for PowerShell, 100ms for other shells + const delay = isPowerShell ? 500 : 100; + setTimeout(() => { if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + "\n" })); + ws.send(JSON.stringify({ type: "input", data: runCommandOnConnect + lineEnding })); onCommandRan?.(); } - }, 100); + }, delay); } break; + } case "exit": terminal.write(`\r\n\x1b[33m[Process exited with code ${msg.exitCode}]\x1b[0m\r\n`); setProcessExitCode(msg.exitCode); @@ -907,10 +1037,20 @@ export function TerminalPanel({ if (event.code === 4004) { setConnectionStatus("disconnected"); - toast.error("Terminal session not found", { - description: "The session may have expired. Please create a new terminal.", - duration: 5000, - }); + // Notify parent that this session is no longer valid on the server + // This allows automatic cleanup of stale sessions (e.g., after server restart) + if (onSessionInvalidRef.current) { + onSessionInvalidRef.current(); + toast.info("Terminal session expired", { + description: "The session was automatically removed. Create a new terminal to continue.", + duration: 5000, + }); + } else { + toast.error("Terminal session not found", { + description: "The session may have expired. Please create a new terminal.", + duration: 5000, + }); + } return; } @@ -1472,6 +1612,7 @@ export function TerminalPanel({ isOver && isDropTarget && "ring-2 ring-green-500 ring-inset" )} onClick={onFocus} + onKeyDownCapture={handleContainerKeyDownCapture} tabIndex={0} data-terminal-container="true" > @@ -1818,7 +1959,7 @@ export function TerminalPanel({ setSearchQuery(e.target.value); // Auto-search as user types if (searchAddonRef.current && e.target.value) { - searchAddonRef.current.findNext(e.target.value, { caseSensitive: false, regex: false }); + searchAddonRef.current.findNext(e.target.value, searchOptions); } else if (searchAddonRef.current) { searchAddonRef.current.clearDecorations(); } diff --git a/apps/ui/src/config/terminal-themes.ts b/apps/ui/src/config/terminal-themes.ts index c4c6ce6a..2aa44209 100644 --- a/apps/ui/src/config/terminal-themes.ts +++ b/apps/ui/src/config/terminal-themes.ts @@ -28,6 +28,11 @@ export interface TerminalTheme { brightMagenta: string; brightCyan: string; brightWhite: string; + // Search highlighting colors - for xterm SearchAddon + searchMatchBackground: string; + searchMatchBorder: string; + searchActiveMatchBackground: string; + searchActiveMatchBorder: string; } /** @@ -77,6 +82,11 @@ const darkTheme: TerminalTheme = { brightMagenta: "#c586c0", brightCyan: "#4ec9b0", brightWhite: "#ffffff", + // Search colors - bright yellow for visibility on dark background + searchMatchBackground: "#6b5300", + searchMatchBorder: "#e2ac00", + searchActiveMatchBackground: "#ff8c00", + searchActiveMatchBorder: "#ffb74d", }; // Light theme @@ -102,6 +112,11 @@ const lightTheme: TerminalTheme = { brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", + // Search colors - darker for visibility on light background + searchMatchBackground: "#fff3b0", + searchMatchBorder: "#c9a500", + searchActiveMatchBackground: "#ffcc00", + searchActiveMatchBorder: "#996600", }; // Retro / Cyberpunk theme - neon green on black @@ -128,6 +143,11 @@ const retroTheme: TerminalTheme = { brightMagenta: "#ff55ff", brightCyan: "#55ffff", brightWhite: "#ffffff", + // Search colors - magenta/pink for contrast with green text + searchMatchBackground: "#660066", + searchMatchBorder: "#ff00ff", + searchActiveMatchBackground: "#cc00cc", + searchActiveMatchBorder: "#ff66ff", }; // Dracula theme @@ -153,6 +173,11 @@ const draculaTheme: TerminalTheme = { brightMagenta: "#ff92df", brightCyan: "#a4ffff", brightWhite: "#ffffff", + // Search colors - orange for visibility + searchMatchBackground: "#8b5a00", + searchMatchBorder: "#ffb86c", + searchActiveMatchBackground: "#ff9500", + searchActiveMatchBorder: "#ffcc80", }; // Nord theme @@ -178,6 +203,11 @@ const nordTheme: TerminalTheme = { brightMagenta: "#b48ead", brightCyan: "#8fbcbb", brightWhite: "#eceff4", + // Search colors - warm yellow/orange for cold blue theme + searchMatchBackground: "#5e4a00", + searchMatchBorder: "#ebcb8b", + searchActiveMatchBackground: "#d08770", + searchActiveMatchBorder: "#e8a87a", }; // Monokai theme @@ -203,6 +233,11 @@ const monokaiTheme: TerminalTheme = { brightMagenta: "#ae81ff", brightCyan: "#a1efe4", brightWhite: "#f9f8f5", + // Search colors - orange/gold for contrast + searchMatchBackground: "#6b4400", + searchMatchBorder: "#f4bf75", + searchActiveMatchBackground: "#e69500", + searchActiveMatchBorder: "#ffd080", }; // Tokyo Night theme @@ -228,6 +263,11 @@ const tokyonightTheme: TerminalTheme = { brightMagenta: "#bb9af7", brightCyan: "#7dcfff", brightWhite: "#c0caf5", + // Search colors - warm orange for cold blue theme + searchMatchBackground: "#5c4a00", + searchMatchBorder: "#e0af68", + searchActiveMatchBackground: "#ff9e64", + searchActiveMatchBorder: "#ffb380", }; // Solarized Dark theme (improved contrast for WCAG compliance) @@ -253,6 +293,11 @@ const solarizedTheme: TerminalTheme = { brightMagenta: "#6c71c4", brightCyan: "#93a1a1", brightWhite: "#fdf6e3", + // Search colors - orange (solarized orange) for visibility + searchMatchBackground: "#5c3d00", + searchMatchBorder: "#b58900", + searchActiveMatchBackground: "#cb4b16", + searchActiveMatchBorder: "#e07040", }; // Gruvbox Dark theme @@ -278,6 +323,11 @@ const gruvboxTheme: TerminalTheme = { brightMagenta: "#d3869b", brightCyan: "#8ec07c", brightWhite: "#ebdbb2", + // Search colors - bright orange for gruvbox + searchMatchBackground: "#6b4500", + searchMatchBorder: "#d79921", + searchActiveMatchBackground: "#fe8019", + searchActiveMatchBorder: "#ffaa40", }; // Catppuccin Mocha theme @@ -303,6 +353,11 @@ const catppuccinTheme: TerminalTheme = { brightMagenta: "#cba6f7", brightCyan: "#94e2d5", brightWhite: "#a6adc8", + // Search colors - peach/orange from catppuccin palette + searchMatchBackground: "#5c4020", + searchMatchBorder: "#fab387", + searchActiveMatchBackground: "#fab387", + searchActiveMatchBorder: "#fcc8a0", }; // One Dark theme @@ -328,6 +383,11 @@ const onedarkTheme: TerminalTheme = { brightMagenta: "#c678dd", brightCyan: "#56b6c2", brightWhite: "#ffffff", + // Search colors - orange/gold for visibility + searchMatchBackground: "#5c4500", + searchMatchBorder: "#e5c07b", + searchActiveMatchBackground: "#d19a66", + searchActiveMatchBorder: "#e8b888", }; // Synthwave '84 theme @@ -353,6 +413,11 @@ const synthwaveTheme: TerminalTheme = { brightMagenta: "#ff7edb", brightCyan: "#03edf9", brightWhite: "#ffffff", + // Search colors - hot pink/magenta for synthwave aesthetic + searchMatchBackground: "#6b2a7a", + searchMatchBorder: "#ff7edb", + searchActiveMatchBackground: "#ff7edb", + searchActiveMatchBorder: "#ffffff", }; // Red theme - Dark theme with red accents @@ -378,6 +443,11 @@ const redTheme: TerminalTheme = { brightMagenta: "#cc77aa", brightCyan: "#77aaaa", brightWhite: "#d0c0c0", + // Search colors - orange/gold to contrast with red theme + searchMatchBackground: "#5a3520", + searchMatchBorder: "#ccaa55", + searchActiveMatchBackground: "#ddbb66", + searchActiveMatchBorder: "#ffdd88", }; // Cream theme - Warm, soft, easy on the eyes @@ -403,6 +473,11 @@ const creamTheme: TerminalTheme = { brightMagenta: "#c080a0", brightCyan: "#70b0a0", brightWhite: "#d0c0b0", + // Search colors - blue for contrast on light cream background + searchMatchBackground: "#c0d4e8", + searchMatchBorder: "#6b8aaa", + searchActiveMatchBackground: "#6b8aaa", + searchActiveMatchBorder: "#4a6a8a", }; // Sunset theme - Mellow oranges and soft pastels @@ -428,6 +503,11 @@ const sunsetTheme: TerminalTheme = { brightMagenta: "#dd88aa", brightCyan: "#88ddbb", brightWhite: "#f5e8dd", + // Search colors - orange for warm sunset theme + searchMatchBackground: "#5a3a30", + searchMatchBorder: "#ddaa66", + searchActiveMatchBackground: "#eebb77", + searchActiveMatchBorder: "#ffdd99", }; // Gray theme - Modern, minimal gray scheme inspired by Cursor @@ -453,6 +533,11 @@ const grayTheme: TerminalTheme = { brightMagenta: "#c098c8", brightCyan: "#80b8c8", brightWhite: "#e0e0e8", + // Search colors - blue for modern feel + searchMatchBackground: "#3a4a60", + searchMatchBorder: "#7090c0", + searchActiveMatchBackground: "#90b0d8", + searchActiveMatchBorder: "#b0d0f0", }; // Theme mapping diff --git a/apps/ui/src/hooks/use-keyboard-shortcuts.ts b/apps/ui/src/hooks/use-keyboard-shortcuts.ts index f5e72d12..2e77e160 100644 --- a/apps/ui/src/hooks/use-keyboard-shortcuts.ts +++ b/apps/ui/src/hooks/use-keyboard-shortcuts.ts @@ -77,6 +77,102 @@ function isInputFocused(): boolean { return false; } +/** + * Convert a key character to its corresponding event.code + * This is used for keyboard-layout independent matching in terminals + */ +function keyToCode(key: string): string { + const upperKey = key.toUpperCase(); + + // Letters A-Z map to KeyA-KeyZ + if (/^[A-Z]$/.test(upperKey)) { + return `Key${upperKey}`; + } + + // Numbers 0-9 on main row map to Digit0-Digit9 + if (/^[0-9]$/.test(key)) { + return `Digit${key}`; + } + + // Special key mappings + const specialMappings: Record = { + "`": "Backquote", + "~": "Backquote", + "-": "Minus", + "_": "Minus", + "=": "Equal", + "+": "Equal", + "[": "BracketLeft", + "{": "BracketLeft", + "]": "BracketRight", + "}": "BracketRight", + "\\": "Backslash", + "|": "Backslash", + ";": "Semicolon", + ":": "Semicolon", + "'": "Quote", + '"': "Quote", + ",": "Comma", + "<": "Comma", + ".": "Period", + ">": "Period", + "/": "Slash", + "?": "Slash", + " ": "Space", + "Enter": "Enter", + "Tab": "Tab", + "Escape": "Escape", + "Backspace": "Backspace", + "Delete": "Delete", + "ArrowUp": "ArrowUp", + "ArrowDown": "ArrowDown", + "ArrowLeft": "ArrowLeft", + "ArrowRight": "ArrowRight", + }; + + return specialMappings[key] || specialMappings[upperKey] || key; +} + +/** + * Check if a keyboard event matches a shortcut definition using event.code + * This is keyboard-layout independent - useful for terminals where Alt+key + * combinations can produce special characters with event.key + */ +export function matchesShortcutWithCode(event: KeyboardEvent, shortcutStr: string): boolean { + const shortcut = parseShortcut(shortcutStr); + if (!shortcut.key) return false; + + // Convert the shortcut key to event.code format + const expectedCode = keyToCode(shortcut.key); + + // Check if the code matches + if (event.code !== expectedCode) { + return false; + } + + // Check modifier keys + const cmdCtrlPressed = event.metaKey || event.ctrlKey; + const shiftPressed = event.shiftKey; + const altPressed = event.altKey; + + // If shortcut requires cmdCtrl, it must be pressed + if (shortcut.cmdCtrl && !cmdCtrlPressed) return false; + // If shortcut doesn't require cmdCtrl, it shouldn't be pressed + if (!shortcut.cmdCtrl && cmdCtrlPressed) return false; + + // If shortcut requires shift, it must be pressed + if (shortcut.shift && !shiftPressed) return false; + // If shortcut doesn't require shift, it shouldn't be pressed + if (!shortcut.shift && shiftPressed) return false; + + // If shortcut requires alt, it must be pressed + if (shortcut.alt && !altPressed) return false; + // If shortcut doesn't require alt, it shouldn't be pressed + if (!shortcut.alt && altPressed) return false; + + return true; +} + /** * Check if a keyboard event matches a shortcut definition */ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index aa1f63f0..5c942a18 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -2239,6 +2239,9 @@ export const useAppStore = create()( ...current, activeTabId: tabId, activeSessionId: newActiveSessionId, + // Clear maximized state when switching tabs - the maximized terminal + // belongs to the previous tab and shouldn't persist across tab switches + maximizedSessionId: null, }, }); }, @@ -2636,6 +2639,36 @@ export const useAppStore = create()( { name: "automaker-storage", version: 2, // Increment when making breaking changes to persisted state + // Custom merge function to properly restore terminal settings on every load + // The default shallow merge doesn't work because we persist terminalSettings + // separately from terminalState (to avoid persisting session data like tabs) + merge: (persistedState, currentState) => { + const persisted = persistedState as Partial & { terminalSettings?: PersistedTerminalSettings }; + const current = currentState as AppState & AppActions; + + // Start with default shallow merge + const merged = { ...current, ...persisted } as AppState & AppActions; + + // Restore terminal settings into terminalState + // terminalSettings is persisted separately from terminalState to avoid + // persisting session data (tabs, activeSessionId, etc.) + if (persisted.terminalSettings) { + merged.terminalState = { + // Start with current (initial) terminalState for session fields + ...current.terminalState, + // Override with persisted settings + defaultFontSize: persisted.terminalSettings.defaultFontSize ?? current.terminalState.defaultFontSize, + defaultRunScript: persisted.terminalSettings.defaultRunScript ?? current.terminalState.defaultRunScript, + screenReaderMode: persisted.terminalSettings.screenReaderMode ?? current.terminalState.screenReaderMode, + fontFamily: persisted.terminalSettings.fontFamily ?? current.terminalState.fontFamily, + scrollbackLines: persisted.terminalSettings.scrollbackLines ?? current.terminalState.scrollbackLines, + lineHeight: persisted.terminalSettings.lineHeight ?? current.terminalState.lineHeight, + maxSessions: persisted.terminalSettings.maxSessions ?? current.terminalState.maxSessions, + }; + } + + return merged; + }, migrate: (persistedState: unknown, version: number) => { const state = persistedState as Partial; diff --git a/docs/terminal.md b/docs/terminal.md index 8a30678f..ae78623f 100644 --- a/docs/terminal.md +++ b/docs/terminal.md @@ -32,11 +32,27 @@ When password protection is enabled: When the terminal is focused, the following shortcuts are available: -| Shortcut | Action | -| -------- | --------------------------------------- | -| `Alt+D` | Split terminal right (horizontal split) | -| `Alt+S` | Split terminal down (vertical split) | -| `Alt+W` | Close current terminal | +| Shortcut | Action | +| -------- | ---------------------------------------- | +| `Alt+T` | Open new terminal tab | +| `Alt+D` | Split terminal right (horizontal split) | +| `Alt+S` | Split terminal down (vertical split) | +| `Alt+W` | Close current terminal | + +These shortcuts are customizable via the keyboard shortcuts settings (Settings > Keyboard Shortcuts). + +### Split Pane Navigation + +Navigate between terminal panes using directional shortcuts: + +| Shortcut | Action | +| --------------------------------- | ----------------------------------- | +| `Ctrl+Alt+ArrowUp` (or `Cmd+Alt`) | Move focus to terminal pane above | +| `Ctrl+Alt+ArrowDown` | Move focus to terminal pane below | +| `Ctrl+Alt+ArrowLeft` | Move focus to terminal pane on left | +| `Ctrl+Alt+ArrowRight` | Move focus to terminal pane on right| + +The navigation is spatially aware - pressing Down will move to the terminal below your current one, not just cycle through terminals in order. Global shortcut (works anywhere in the app): | Shortcut | Action |