From c5a990e48f0aabb09c04701d210f8bcb6fe95866 Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Wed, 13 May 2026 22:29:27 -0700 Subject: [PATCH] fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons (#2442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons The Quick Ask/Command revamp (#2139) replaced an Obsidian Modal with a standalone DraggableModal. Two regressions slipped through: - The new modal didn't push a Scope, so any user-bound global Cmd+Enter hotkey would fire instead of Replace. Push a Scope in `CustomCommandChatModal.open()` that registers Mod+Enter and Mod+Shift+Enter as no-op consumers (the React onKeyDown does the real work) and pop it in close(), matching what `Modal` did automatically. - The Insert/Replace buttons lost the inline ⌘↩ / ⌘⇧↩ glyph hints that existed in the pre-revamp modal. Re-add them with Lucide icons (`Command`, `ArrowBigUp`, `CornerDownLeft`) in `action-buttons.tsx`, with a Ctrl-text fallback on non-mac. Also move the shortcut handler from a document-level keydown listener back to a React `onKeyDown` on the modal subtree — same pattern as the original implementation — so it can't be pre-empted by capture-phase listeners. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(quick-command): widen dialog so footer fits Note checkbox + shortcut glyphs Quick Command mode renders an extra "Note" context checkbox alongside the model selector and action buttons. With the restored ⌘⇧↩ / ⌘↩ glyphs on Insert/Replace, the default 500px footer overflows. Bump the initial width to `min(620px, 92vw)` only when `hideContentAreaOnIdle` (the Quick Command signal) is set; Custom Commands keep the original width. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(quick-command): widen modal unconditionally instead of gating on mode Apply the wider footer to Custom Commands too — same overflow potential once the Insert/Replace glyph hints are present. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- src/commands/CustomCommandChatModal.tsx | 15 +- src/components/command-ui/action-buttons.tsx | 33 +++ .../command-ui/menu-command-modal.tsx | 221 ++++++++---------- 3 files changed, 146 insertions(+), 123 deletions(-) diff --git a/src/commands/CustomCommandChatModal.tsx b/src/commands/CustomCommandChatModal.tsx index b5556779..554cd67c 100644 --- a/src/commands/CustomCommandChatModal.tsx +++ b/src/commands/CustomCommandChatModal.tsx @@ -13,7 +13,7 @@ import { computeVerticalPlacement } from "@/utils/panelPlacement"; import { computeSelectionAnchors } from "@/utils/selectionAnchors"; import type { EditorView } from "@codemirror/view"; import { PenLine } from "lucide-react"; -import { App, Component, MarkdownRenderer, Notice, MarkdownView } from "obsidian"; +import { App, Component, MarkdownRenderer, Notice, MarkdownView, Scope } from "obsidian"; import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { preprocessAIResponse } from "@/utils/markdownPreprocess"; import { createRoot, Root } from "react-dom/client"; @@ -486,6 +486,7 @@ export class CustomCommandChatModal { private container: HTMLElement | null = null; private highlightView: EditorView | null = null; private replaceGuard: ReplaceGuard | null = null; + private scope: Scope | null = null; constructor( private app: App, @@ -646,6 +647,14 @@ export class CustomCommandChatModal { } open() { + // Reason: Push a Scope so user-bound global Cmd/Ctrl+Enter hotkeys don't fire + // while this modal is open. The Scope handlers return true to consume the event; + // the React onKeyDown inside MenuCommandModal does the actual Replace/Insert. + this.scope = new Scope(); + this.scope.register(["Mod"], "Enter", () => true); + this.scope.register(["Mod", "Shift"], "Enter", () => true); + this.app.keymap.pushScope(this.scope); + // Reason: Capture activeView once at open time to ensure document/window/editor // all reference the same view. Avoids subtle inconsistencies if the user switches // tabs between resolveDocument() and the selection snapshot below. @@ -732,6 +741,10 @@ export class CustomCommandChatModal { } close() { + if (this.scope) { + this.app.keymap.popScope(this.scope); + this.scope = null; + } // Hide selection highlight if (this.highlightView) { SelectionHighlight.hide(this.highlightView); diff --git a/src/components/command-ui/action-buttons.tsx b/src/components/command-ui/action-buttons.tsx index e4be2a8f..4be9c8c3 100644 --- a/src/components/command-ui/action-buttons.tsx +++ b/src/components/command-ui/action-buttons.tsx @@ -1,10 +1,41 @@ import * as React from "react"; import { Platform } from "obsidian"; +import { ArrowBigUp, Command, CornerDownLeft } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; type ActionState = "idle" | "loading" | "result"; +const ICON_CLS = "tw-size-3"; + +const ReplaceShortcutHint = () => + Platform.isMacOS ? ( + + + + + ) : ( + + Ctrl + + + ); + +const InsertShortcutHint = () => + Platform.isMacOS ? ( + + + + + + ) : ( + + Ctrl + + + + ); + interface ActionButtonsProps { state: ActionState; onStop?: () => void; @@ -58,6 +89,7 @@ export function ActionButtons({ title={`Insert below selection (${Platform.isMacOS ? "⌘" : "Ctrl"}+Shift+Enter)`} > Insert + )} diff --git a/src/components/command-ui/menu-command-modal.tsx b/src/components/command-ui/menu-command-modal.tsx index 98c93df8..fa887b9b 100644 --- a/src/components/command-ui/menu-command-modal.tsx +++ b/src/components/command-ui/menu-command-modal.tsx @@ -1,5 +1,4 @@ import * as React from "react"; -import { useEffect, useRef } from "react"; import { Send } from "lucide-react"; import { Platform } from "obsidian"; import { DraggableModal } from "./draggable-modal"; @@ -107,46 +106,24 @@ export function MenuCommandModal({ const isEditable = contentState.type === "result" && !contentState.isStreaming && !!onEditableContentChange; - // Ref to an element inside this modal, used to resolve ownerDocument and scope shortcuts - const innerRef = useRef(null); + // Keyboard shortcuts: Ctrl/Cmd+Enter → Replace, Ctrl/Cmd+Shift+Enter → Insert. + // Attached as a React onKeyDown on the modal subtree so it runs before any global + // capture-phase keymap (Obsidian hotkeys, other plugins) can consume the event. + const handleKeyDown = (e: React.KeyboardEvent) => { + if (actionState !== "result") return; + if (e.nativeEvent.isComposing) return; - // Keyboard shortcuts: Ctrl+Enter → Replace, Ctrl+Shift+Enter → Insert - // Mirrors DraggableModal's Escape handling: uses ownerDocument for popout windows, - // and scopes to the focused modal to avoid firing across multiple open modals. - useEffect(() => { - if (!open) return; + const modKey = Platform.isMacOS ? e.metaKey : e.ctrlKey; + if (e.key !== "Enter" || !modKey) return; - const modalEl = innerRef.current?.closest('[data-copilot-draggable-modal="true"]'); - const ownerDocument = modalEl?.doc ?? activeDocument; - - const handleKeyDown = (e: KeyboardEvent) => { - // Only handle when result is ready and not busy - if (actionState !== "result") return; - - const modKey = Platform.isMacOS ? e.metaKey : e.ctrlKey; - if (e.key !== "Enter" || !modKey) return; - - // Scope to focused modal: only handle if active element is inside this modal. - // Avoid `instanceof Element` — it fails cross-realm (popout windows have their own Element). - const activeEl = ownerDocument.activeElement; - if (modalEl && (!activeEl || !modalEl.contains(activeEl))) return; - - if (e.shiftKey) { - // Ctrl/Cmd + Shift + Enter → Insert - e.preventDefault(); - e.stopPropagation(); - onInsert?.(); - } else { - // Ctrl/Cmd + Enter → Replace - e.preventDefault(); - e.stopPropagation(); - onReplace?.(); - } - }; - - ownerDocument.addEventListener("keydown", handleKeyDown); - return () => ownerDocument.removeEventListener("keydown", handleKeyDown); - }, [open, actionState, onInsert, onReplace]); + e.preventDefault(); + e.stopPropagation(); + if (e.shiftKey) { + onInsert?.(); + } else { + onReplace?.(); + } + }; // Conditionally show ContentArea based on hideContentAreaOnIdle prop const showContentArea = hideContentAreaOnIdle ? contentState.type !== "idle" : true; @@ -162,95 +139,95 @@ export function MenuCommandModal({ anchorBottom={anchorBottom} resizable={resizable} minHeight={resizable ? dynamicMinHeight : undefined} + width="min(620px, 92vw)" closeOnEscapeFromOutside > - {/* Command Label - flex-none */} - - - {/* Content Area - flex-1, editable when result is ready */} - {showContentArea && ( - + {/* Command Label - flex-none */} + - )} - {/* Follow-up Input - flex-none, allow typing during streaming but disable submit */} - { - if (!isBusy) onFollowUpSubmit(); - }} - onClear={() => onFollowUpChange("")} - placeholder="Enter follow-up instructions..." - className={!showContentArea ? "tw-mt-auto" : undefined} - hint={isBusy ? "Generating..." : undefined} - autoFocus - /> - - {/* Bottom Toolbar - flex-none */} -
-
- - {onIncludeNoteContextChange && ( -
- onIncludeNoteContextChange(!!checked)} - className="tw-size-3.5" - disabled={isBusy} - /> - - -
- )} -
-
- {/* Send button: shown when content area is hidden (idle Quick Command mode) */} - {hideContentAreaOnIdle && actionState === "idle" && ( - - )} - + variant="ghost" + value={selectedModel} + onChange={onSelectModel} + disabled={isBusy} + /> + {onIncludeNoteContextChange && ( +
+ onIncludeNoteContextChange(!!checked)} + className="tw-size-3.5" + disabled={isBusy} + /> + + +
+ )} +
+
+ {/* Send button: shown when content area is hidden (idle Quick Command mode) */} + {hideContentAreaOnIdle && actionState === "idle" && ( + + )} + +