diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index f0039cc34a..0fa723963c 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -20,6 +20,7 @@ import { EditorCommentPopover } from '@/components/task/editor-comment-popover'; import { CommentViewPopover } from '@/components/task/comment-view-popover'; import { PanelHeaderBarSplit } from '@/components/task/panel-primitives'; import { useDockviewStore } from '@/lib/state/dockview-store'; +import { useCommandPanelOpen } from '@/lib/commands/command-registry'; type FileEditorContentProps = { path: string; @@ -153,6 +154,7 @@ export function CodeMirrorCodeEditor({ const wrapperRef = useRef(null); const mousePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const { toast } = useToast(); + const { setOpen: setCommandPanelOpen } = useCommandPanelOpen(); // Comment store const addComment = useDiffCommentsStore((state) => state.addComment); @@ -396,6 +398,36 @@ export function CodeMirrorCodeEditor({ return () => wrapper.removeEventListener('keydown', handleKeyDown, true); }, [enableComments, sessionId, currentSelection, floatingButtonPos]); + // Cmd/Ctrl+K to open command panel — capture phase intercepts before CodeMirror + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + e.stopPropagation(); + setCommandPanelOpen(true); + } + }; + wrapper.addEventListener('keydown', handler, true); + return () => wrapper.removeEventListener('keydown', handler, true); + }, [setCommandPanelOpen]); + + // Alt+Z to toggle word wrap — capture phase + useEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + const handler = (e: KeyboardEvent) => { + if (e.altKey && !e.metaKey && !e.ctrlKey && e.code === 'KeyZ') { + e.preventDefault(); + e.stopPropagation(); + setWrapEnabled((prev) => !prev); + } + }; + wrapper.addEventListener('keydown', handler, true); + return () => wrapper.removeEventListener('keydown', handler, true); + }, []); + // Handle Cmd/Ctrl+S keyboard shortcut and Escape useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx index 9795b73df5..396e6027ad 100644 --- a/apps/web/components/editors/monaco/monaco-code-editor.tsx +++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx @@ -228,7 +228,7 @@ export function MonacoCodeEditor({ // Handle editor mount const handleEditorDidMount: OnMount = useCallback( - (editor, _monaco) => { + (editor, monaco) => { editorRef.current = editor; setEditorInstance(editor); decorationsRef.current = editor.createDecorationsCollection([]); @@ -288,8 +288,20 @@ export function MonacoCodeEditor({ } }); disposablesRef.current.push(glyphDisposable); + + // Override Monaco's built-in Cmd+K chord to open command panel + editor.addCommand( + monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyK, + () => { setCommandPanelOpen(true); } + ); + + // Alt+Z to toggle word wrap + editor.addCommand( + monaco.KeyMod.Alt | monaco.KeyCode.KeyZ, + () => { setWrapEnabled((prev) => !prev); } + ); }, - [path, enableComments, sessionId] + [path, enableComments, sessionId, setCommandPanelOpen, setWrapEnabled] ); // Cleanup disposables @@ -476,21 +488,6 @@ export function MonacoCodeEditor({ return () => wrapper.removeEventListener('keydown', handleKeyDown, true); }, [enableComments, sessionId, currentSelection]); - // Cmd+K for command panel — capture phase prevents Monaco from swallowing the shortcut - useEffect(() => { - const wrapper = wrapperRef.current; - if (!wrapper) return; - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { - e.preventDefault(); - e.stopPropagation(); - setCommandPanelOpen(true); - } - }; - wrapper.addEventListener('keydown', handleKeyDown, true); - return () => wrapper.removeEventListener('keydown', handleKeyDown, true); - }, [setCommandPanelOpen]); - // Escape to close inline forms useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 0139bce12d..a943413552 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -43,6 +43,7 @@ import { startProcess } from '@/lib/api'; import { createUserShell } from '@/lib/api/domains/user-shell-api'; import { useRepositoryScripts } from '@/hooks/domains/workspace/use-repository-scripts'; import { useLspFileOpener } from '@/hooks/use-lsp-file-opener'; +import { useEditorKeybinds } from '@/hooks/use-editor-keybinds'; import { useSessionGitStatus } from '@/hooks/domains/session/use-session-git-status'; import { useSessionCommits } from '@/hooks/domains/session/use-session-commits'; @@ -614,6 +615,9 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ // Connect LSP Go-to-Definition navigation to dockview file tabs useLspFileOpener(); + // Global editor keybinds (tab nav, terminal toggle) + useEditorKeybinds(); + // Keep sessionIdRef in sync for use inside event handlers useEffect(() => { sessionIdRef.current = effectiveSessionId; diff --git a/apps/web/hooks/use-editor-keybinds.ts b/apps/web/hooks/use-editor-keybinds.ts new file mode 100644 index 0000000000..a197ae8ac6 --- /dev/null +++ b/apps/web/hooks/use-editor-keybinds.ts @@ -0,0 +1,107 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { useDockviewStore } from '@/lib/state/dockview-store'; +import { useAppStoreApi } from '@/components/state-provider'; +import { createUserShell } from '@/lib/api/domains/user-shell-api'; +import type { DockviewApi } from 'dockview-react'; + +function handleTabNavigation(e: KeyboardEvent, api: DockviewApi) { + const activePanel = api.activePanel; + if (!activePanel) return; + + const panels = activePanel.group.panels; + if (panels.length <= 1) return; + + const currentIndex = panels.findIndex((p) => p.id === activePanel.id); + if (currentIndex === -1) return; + + e.preventDefault(); + e.stopPropagation(); + + const direction = e.code === 'BracketLeft' ? -1 : 1; + const nextIndex = (currentIndex + direction + panels.length) % panels.length; + panels[nextIndex].api.setActive(); +} + +function handleTerminalToggle( + e: KeyboardEvent, + api: DockviewApi, + previousPanelIdRef: React.MutableRefObject, + getSessionId: () => string | null, +) { + e.preventDefault(); + e.stopPropagation(); + + const activePanel = api.activePanel; + const isTerminalFocused = activePanel?.id.startsWith('terminal-') ?? false; + + if (isTerminalFocused) { + const prevId = previousPanelIdRef.current; + const target = prevId ? api.getPanel(prevId) : api.getPanel('chat'); + if (target) target.api.setActive(); + previousPanelIdRef.current = null; + return; + } + + if (activePanel) { + previousPanelIdRef.current = activePanel.id; + } + + const terminalPanel = api.panels.find((p) => p.id.startsWith('terminal-')); + if (terminalPanel) { + terminalPanel.api.setActive(); + return; + } + + const sessionId = getSessionId(); + if (!sessionId) return; + + createUserShell(sessionId) + .then((result) => { + useDockviewStore.getState().addTerminalPanel(result.terminalId); + }) + .catch((err) => { console.warn('Failed to create terminal shell:', err); }); +} + +/** + * Global editor keybinds for dockview: + * - Cmd/Ctrl+Shift+[ / ] — navigate prev/next tab in active group + * - Ctrl+` — toggle terminal focus + */ +export function useEditorKeybinds() { + const previousPanelIdRef = useRef(null); + const appStore = useAppStoreApi(); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + const api = useDockviewStore.getState().api; + if (!api) return; + + const isTabNav = + (e.metaKey || e.ctrlKey) && + e.shiftKey && + (e.code === 'BracketLeft' || e.code === 'BracketRight'); + + if (isTabNav) { + handleTabNavigation(e, api); + return; + } + + const isTerminalToggle = + e.ctrlKey && !e.metaKey && !e.shiftKey && e.code === 'Backquote'; + + if (isTerminalToggle) { + handleTerminalToggle( + e, + api, + previousPanelIdRef, + () => appStore.getState().tasks.activeSessionId, + ); + } + }; + + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [appStore]); +}