fix(quick-command): restore Cmd+Enter shortcut and shortcut hint icons (#2442)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zero Liu 2026-05-13 22:29:27 -07:00 committed by GitHub
parent 020e24507c
commit c5a990e48f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 146 additions and 123 deletions

View file

@ -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);

View file

@ -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 ? (
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-opacity-80">
<Command className={ICON_CLS} />
<CornerDownLeft className={ICON_CLS} />
</span>
) : (
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-text-xs tw-opacity-80">
Ctrl
<CornerDownLeft className={ICON_CLS} />
</span>
);
const InsertShortcutHint = () =>
Platform.isMacOS ? (
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-opacity-80">
<Command className={ICON_CLS} />
<ArrowBigUp className={ICON_CLS} />
<CornerDownLeft className={ICON_CLS} />
</span>
) : (
<span className="tw-ml-1 tw-inline-flex tw-items-center tw-gap-0.5 tw-text-xs tw-opacity-80">
Ctrl
<ArrowBigUp className={ICON_CLS} />
<CornerDownLeft className={ICON_CLS} />
</span>
);
interface ActionButtonsProps {
state: ActionState;
onStop?: () => void;
@ -58,6 +89,7 @@ export function ActionButtons({
title={`Insert below selection (${Platform.isMacOS ? "⌘" : "Ctrl"}+Shift+Enter)`}
>
Insert
<InsertShortcutHint />
</Button>
<Button
size="sm"
@ -65,6 +97,7 @@ export function ActionButtons({
title={`Replace selection (${Platform.isMacOS ? "⌘" : "Ctrl"}+Enter)`}
>
Replace
<ReplaceShortcutHint />
</Button>
</>
)}

View file

@ -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<HTMLDivElement>(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<HTMLElement>('[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 */}
<CommandLabel
icon={commandIcon}
label={commandLabel}
className="tw-border-b tw-border-border"
/>
{/* Content Area - flex-1, editable when result is ready */}
{showContentArea && (
<ContentArea
state={contentState}
editable={isEditable}
value={editableContent}
onChange={onEditableContentChange}
disableAutoGrow={resizable}
minHeight={resizable ? "0px" : undefined}
renderMarkdown={renderMarkdown}
<div onKeyDown={handleKeyDown} className="tw-flex tw-min-h-0 tw-flex-1 tw-flex-col">
{/* Command Label - flex-none */}
<CommandLabel
icon={commandIcon}
label={commandLabel}
className="tw-border-b tw-border-border"
/>
)}
{/* Follow-up Input - flex-none, allow typing during streaming but disable submit */}
<FollowUpInput
value={followUpValue}
onChange={onFollowUpChange}
onSubmit={() => {
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 */}
<div
ref={innerRef}
className="tw-flex tw-flex-none tw-items-center tw-justify-between tw-border-t tw-border-border tw-px-4 tw-py-3"
>
<div className="tw-flex tw-items-center tw-gap-3">
<ModelSelector
size="sm"
variant="ghost"
value={selectedModel}
onChange={onSelectModel}
disabled={isBusy}
{/* Content Area - flex-1, editable when result is ready */}
{showContentArea && (
<ContentArea
state={contentState}
editable={isEditable}
value={editableContent}
onChange={onEditableContentChange}
disableAutoGrow={resizable}
minHeight={resizable ? "0px" : undefined}
renderMarkdown={renderMarkdown}
/>
{onIncludeNoteContextChange && (
<div className="tw-flex tw-items-center tw-gap-1.5">
<Checkbox
id="menuCommandIncludeContext"
checked={includeNoteContext}
onCheckedChange={(checked) => onIncludeNoteContextChange(!!checked)}
className="tw-size-3.5"
disabled={isBusy}
/>
<label
htmlFor="menuCommandIncludeContext"
className="tw-cursor-pointer tw-text-xs tw-text-muted"
>
Note
</label>
<HelpTooltip content="Include the active note's content as context" side="top" />
</div>
)}
</div>
<div className="tw-flex tw-items-center tw-gap-2">
{/* Send button: shown when content area is hidden (idle Quick Command mode) */}
{hideContentAreaOnIdle && actionState === "idle" && (
<Button
variant="default"
)}
{/* Follow-up Input - flex-none, allow typing during streaming but disable submit */}
<FollowUpInput
value={followUpValue}
onChange={onFollowUpChange}
onSubmit={() => {
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 */}
<div className="tw-flex tw-flex-none tw-items-center tw-justify-between tw-border-t tw-border-border tw-px-4 tw-py-3">
<div className="tw-flex tw-items-center tw-gap-3">
<ModelSelector
size="sm"
onClick={onFollowUpSubmit}
disabled={!followUpValue.trim() || isBusy}
title="Send message"
>
<Send className="tw-mr-1 tw-size-4" />
Send
</Button>
)}
<ActionButtons
state={actionState}
onStop={onStop}
onCopy={onCopy}
onInsert={onInsert}
onReplace={onReplace}
/>
variant="ghost"
value={selectedModel}
onChange={onSelectModel}
disabled={isBusy}
/>
{onIncludeNoteContextChange && (
<div className="tw-flex tw-items-center tw-gap-1.5">
<Checkbox
id="menuCommandIncludeContext"
checked={includeNoteContext}
onCheckedChange={(checked) => onIncludeNoteContextChange(!!checked)}
className="tw-size-3.5"
disabled={isBusy}
/>
<label
htmlFor="menuCommandIncludeContext"
className="tw-cursor-pointer tw-text-xs tw-text-muted"
>
Note
</label>
<HelpTooltip content="Include the active note's content as context" side="top" />
</div>
)}
</div>
<div className="tw-flex tw-items-center tw-gap-2">
{/* Send button: shown when content area is hidden (idle Quick Command mode) */}
{hideContentAreaOnIdle && actionState === "idle" && (
<Button
variant="default"
size="sm"
onClick={onFollowUpSubmit}
disabled={!followUpValue.trim() || isBusy}
title="Send message"
>
<Send className="tw-mr-1 tw-size-4" />
Send
</Button>
)}
<ActionButtons
state={actionState}
onStop={onStop}
onCopy={onCopy}
onInsert={onInsert}
onReplace={onReplace}
/>
</div>
</div>
</div>
</DraggableModal>