fix(web): scope and label clarification shortcuts (#1847)

This commit is contained in:
Carlos Florêncio
2026-07-21 20:41:41 +01:00
committed by GitHub
parent d144c16fc2
commit ea6bfcd15f
10 changed files with 352 additions and 96 deletions
@@ -64,7 +64,7 @@ export function KeyboardShortcutTooltip({
* @example
* ```tsx
* <KeyboardShortcutText shortcut={SHORTCUTS.SUBMIT} />
* // Renders: Cmd+Enter (on Mac) or Ctrl+Enter (on Windows/Linux)
* // Renders: ⌘+Enter (on Mac) or Ctrl+Enter (on Windows/Linux)
* ```
*/
export function KeyboardShortcutText({ shortcut }: { shortcut: KeyboardShortcut }) {
@@ -1,6 +1,6 @@
"use client";
import { memo, useCallback, useEffect, useId, useRef, useState } from "react";
import { memo, useCallback, useEffect, useId, useRef, useState, type RefObject } from "react";
import { IconChevronDown, IconChevronUp, IconMessageQuestion } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useSettingsData } from "@/hooks/domains/settings/use-settings-data";
@@ -16,6 +16,7 @@ import { ClarificationInputOverlay } from "@/components/task/chat/clarification-
import { ResizeHandle } from "@/components/task/chat/resize-handle";
import { useResizableClarificationOverlay } from "@/hooks/use-resizable-clarification-overlay";
import type { Message } from "@/lib/types/http";
import { routePanelMouseDown } from "@/components/task/chat/route-panel-mouse-down";
type QuickChatContentProps = {
sessionId: string;
@@ -29,12 +30,14 @@ type QuickChatClarificationSectionProps = {
pending: boolean;
messages: readonly Message[] | null | undefined;
onResolved: () => void;
shortcutScopeRef: RefObject<HTMLElement | null>;
};
function QuickChatClarificationSection({
pending,
messages,
onResolved,
shortcutScopeRef,
}: QuickChatClarificationSectionProps) {
const [collapsed, setCollapsed] = useState(false);
const contentId = useId();
@@ -99,6 +102,7 @@ function QuickChatClarificationSection({
<ClarificationInputOverlay
messages={messages}
onResolved={onResolved}
shortcutScopeRef={shortcutScopeRef}
keyboardShortcutsEnabled={!collapsed}
/>
</div>
@@ -137,6 +141,7 @@ export const QuickChatContent = memo(function QuickChatContent({
}: QuickChatContentProps) {
const [clarificationKey, setClarificationKey] = useState(0);
const initialPromptSentFor = useRef<string | null>(null);
const shortcutScopeRef = useRef<HTMLDivElement>(null);
const state = useQuickChatState(sessionId);
const { chatInputRef, panelState, isSending, handleSubmit, handleCancelTurn } = state;
const { taskId, pendingClarification, pendingClarificationGroup } = panelState;
@@ -154,9 +159,19 @@ export const QuickChatContent = memo(function QuickChatContent({
}, [initialPrompt, taskId, handleSubmit, onInitialPromptSent, sessionId]);
const handleClarificationResolved = useCallback(() => setClarificationKey((k) => k + 1), []);
const handleShortcutScopeMouseDown = useCallback(
(event: React.MouseEvent<HTMLDivElement>) => routePanelMouseDown(event, shortcutScopeRef),
[],
);
return (
<div className="flex flex-col flex-1 min-h-0">
<div
ref={shortcutScopeRef}
data-testid="quick-chat-content"
tabIndex={-1}
onMouseDown={handleShortcutScopeMouseDown}
className="flex flex-col flex-1 min-h-0 outline-none"
>
<div className="flex-1 min-h-0 overflow-hidden bg-popover" data-testid="quick-chat-messages">
<MessageList
items={panelState.groupedItems}
@@ -177,6 +192,7 @@ export const QuickChatContent = memo(function QuickChatContent({
pending={Boolean(pendingClarification)}
messages={pendingClarificationGroup}
onResolved={handleClarificationResolved}
shortcutScopeRef={shortcutScopeRef}
/>
<ChatInputArea
chatInputRef={chatInputRef}
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { IconX, IconMessageQuestion, IconInfoCircle, IconCheck } from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown";
@@ -12,6 +12,8 @@ import type {
ClarificationQuestion,
} from "@/lib/types/http";
import { useClarificationGroup } from "@/hooks/domains/session/use-clarification-group";
import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import {
ClarificationCarouselNav,
ClarificationCustomInput,
@@ -22,6 +24,7 @@ import {
type ClarificationInputOverlayProps = {
messages: readonly Message[] | null | undefined;
onResolved: () => void;
shortcutScopeRef: RefObject<HTMLElement | null>;
keyboardShortcutsEnabled?: boolean;
};
@@ -169,6 +172,7 @@ function useResolveCallback(
type CarouselShortcutArgs = {
enabled: boolean;
scopeRef: RefObject<HTMLElement | null>;
meta: SingleQuestionMeta;
activeIndex: number;
total: number;
@@ -211,13 +215,14 @@ function tryHandleMetaEnter(e: KeyboardEvent, canSubmit: boolean, onSubmit: () =
}
function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) {
const { enabled } = args;
const { enabled, scopeRef } = args;
const optionsCount = args.meta.question.options.length;
const isLast = args.activeIndex === args.total - 1;
const { canSubmit, onPick, onPrev, onNext, onSkip, onSubmit } = args;
useEffect(() => {
if (!enabled) return;
const onKey = (e: KeyboardEvent) => {
if (!(e.target instanceof Node) || !scopeRef.current?.contains(e.target)) return;
if (tryHandleMetaEnter(e, canSubmit, onSubmit)) return;
if (shouldIgnoreShortcut(e)) return;
if (e.key === "Escape") {
@@ -244,7 +249,18 @@ function CarouselKeyboardShortcuts(args: CarouselShortcutArgs) {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [enabled, optionsCount, isLast, canSubmit, onPick, onPrev, onNext, onSkip, onSubmit]);
}, [
enabled,
scopeRef,
optionsCount,
isLast,
canSubmit,
onPick,
onPrev,
onNext,
onSkip,
onSubmit,
]);
return null;
}
@@ -257,6 +273,7 @@ type CarouselBodyProps = {
setCustomDrafts: React.Dispatch<React.SetStateAction<Record<string, string>>>;
allAnswered: boolean;
isSubmitting: boolean;
shortcutScopeRef: RefObject<HTMLElement | null>;
keyboardShortcutsEnabled: boolean;
onSubmit: () => void;
};
@@ -362,6 +379,7 @@ function ClarificationCarouselBody({
setCustomDrafts,
allAnswered,
isSubmitting,
shortcutScopeRef,
keyboardShortcutsEnabled,
onSubmit,
}: CarouselBodyProps) {
@@ -418,7 +436,8 @@ function ClarificationCarouselBody({
/>
)}
<CarouselKeyboardShortcuts
enabled={keyboardShortcutsEnabled}
enabled={keyboardShortcutsEnabled && !isSubmitting}
scopeRef={shortcutScopeRef}
meta={meta}
activeIndex={activeIndex}
total={total}
@@ -433,9 +452,76 @@ function ClarificationCarouselBody({
);
}
function ClarificationHeaderActions({
total,
allAnswered,
isSubmitting,
onSubmit,
onSkip,
}: {
total: number;
allAnswered: boolean;
isSubmitting: boolean;
onSubmit: () => void;
onSkip: () => void;
}) {
return (
<div className="flex items-center gap-2">
{total > 1 && (
<KeyboardShortcutTooltip
shortcut={SHORTCUTS.SUBMIT}
description="Submit answers"
enabled={!isSubmitting}
>
<span
className="inline-flex"
data-testid="clarification-submit-shortcut"
tabIndex={!allAnswered && !isSubmitting ? 0 : undefined}
>
<button
type="button"
onClick={onSubmit}
disabled={!allAnswered || isSubmitting}
data-testid="clarification-submit"
className={cn(
"inline-flex items-center gap-1 text-xs px-3 py-1 rounded font-medium transition-colors",
allAnswered && !isSubmitting
? "bg-blue-500 text-white hover:bg-blue-500/90 cursor-pointer"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{isSubmitting ? "Submitting…" : "Submit"}
<IconCheck className="h-3 w-3" />
</button>
</span>
</KeyboardShortcutTooltip>
)}
<KeyboardShortcutTooltip
shortcut={SHORTCUTS.CANCEL}
description="Skip all questions"
enabled={!isSubmitting}
>
<span className="inline-flex" data-testid="clarification-skip-shortcut">
<button
type="button"
onClick={onSkip}
disabled={isSubmitting}
className="text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
data-testid="clarification-skip"
aria-label="Skip all questions"
>
<IconX className="h-4 w-4" />
</button>
</span>
</KeyboardShortcutTooltip>
</div>
);
}
export function ClarificationInputOverlay({
messages,
onResolved,
shortcutScopeRef,
keyboardShortcutsEnabled = true,
}: ClarificationInputOverlayProps) {
const sortedMessages = useMemo(
@@ -492,35 +578,13 @@ export function ClarificationInputOverlay({
</span>
)}
</div>
<div className="flex items-center gap-2">
{total > 1 && (
<button
type="button"
onClick={handleSubmit}
disabled={!allAnswered || isSubmitting}
data-testid="clarification-submit"
className={cn(
"inline-flex items-center gap-1 text-xs px-3 py-1 rounded font-medium transition-colors",
allAnswered && !isSubmitting
? "bg-blue-500 text-white hover:bg-blue-500/90 cursor-pointer"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{isSubmitting ? "Submitting…" : "Submit"}
<IconCheck className="h-3 w-3" />
</button>
)}
<button
type="button"
onClick={() => void group.skipAll("User skipped")}
disabled={isSubmitting}
className="text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
data-testid="clarification-skip"
aria-label="Skip all questions"
>
<IconX className="h-4 w-4" />
</button>
</div>
<ClarificationHeaderActions
total={total}
allAnswered={allAnswered}
isSubmitting={isSubmitting}
onSubmit={handleSubmit}
onSkip={() => void group.skipAll("User skipped")}
/>
</div>
<ClarificationCarouselBody
sortedMessages={sortedMessages}
@@ -531,6 +595,7 @@ export function ClarificationInputOverlay({
setCustomDrafts={setCustomDrafts}
allAnswered={allAnswered}
isSubmitting={isSubmitting}
shortcutScopeRef={shortcutScopeRef}
keyboardShortcutsEnabled={keyboardShortcutsEnabled}
onSubmit={handleSubmit}
/>
@@ -6,6 +6,8 @@ import { Textarea } from "@kandev/ui/textarea";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { cn } from "@/lib/utils";
import type { ClarificationOption } from "@/lib/types/http";
import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip";
import { KEYS } from "@/lib/keyboard/constants";
// Grow the custom-answer box up to ~6 lines, then scroll internally so the
// clarification overlay stays compact.
@@ -328,36 +330,52 @@ export function ClarificationCarouselNav({
const isLast = activeIndex === total - 1;
return (
<div className="flex items-center justify-between gap-2 px-4 pb-3">
<button
type="button"
onClick={onPrev}
disabled={isFirst || isSubmitting}
data-testid="clarification-prev"
className={cn(
"inline-flex items-center gap-1 text-xs px-2 py-1 rounded border",
isFirst
? "border-transparent text-muted-foreground/40 cursor-not-allowed"
: "border-border text-foreground/80 hover:bg-muted/50 cursor-pointer",
)}
<KeyboardShortcutTooltip
shortcut={{ key: KEYS.ARROW_LEFT }}
description="Previous question"
enabled={!isFirst && !isSubmitting}
>
<IconArrowLeft className="h-3 w-3" />
Back
</button>
<button
type="button"
onClick={onNext}
disabled={isLast || isSubmitting}
data-testid="clarification-next"
className={cn(
"inline-flex items-center gap-1 text-xs px-2 py-1 rounded border",
isLast
? "border-transparent text-muted-foreground/40 cursor-not-allowed"
: "border-border text-foreground/80 hover:bg-muted/50 cursor-pointer",
)}
<span className="inline-flex">
<button
type="button"
onClick={onPrev}
disabled={isFirst || isSubmitting}
data-testid="clarification-prev"
className={cn(
"inline-flex items-center gap-1 text-xs px-2 py-1 rounded border",
isFirst
? "border-transparent text-muted-foreground/40 cursor-not-allowed"
: "border-border text-foreground/80 hover:bg-muted/50 cursor-pointer",
)}
>
<IconArrowLeft className="h-3 w-3" />
Back
</button>
</span>
</KeyboardShortcutTooltip>
<KeyboardShortcutTooltip
shortcut={{ key: KEYS.ARROW_RIGHT }}
description="Next question"
enabled={!isLast && !isSubmitting}
>
Next
<IconArrowRight className="h-3 w-3" />
</button>
<span className="inline-flex">
<button
type="button"
onClick={onNext}
disabled={isLast || isSubmitting}
data-testid="clarification-next"
className={cn(
"inline-flex items-center gap-1 text-xs px-2 py-1 rounded border",
isLast
? "border-transparent text-muted-foreground/40 cursor-not-allowed"
: "border-border text-foreground/80 hover:bg-muted/50 cursor-pointer",
)}
>
Next
<IconArrowRight className="h-3 w-3" />
</button>
</span>
</KeyboardShortcutTooltip>
</div>
);
}
@@ -0,0 +1,13 @@
import type { MouseEvent, RefObject } from "react";
const interactiveSelector =
"input, textarea, select, button, a, [contenteditable], [tabindex]:not([tabindex='-1'])";
export function routePanelMouseDown(
event: MouseEvent<HTMLDivElement>,
ref: RefObject<HTMLDivElement | null>,
): void {
const target = event.target as HTMLElement | null;
if (!target || target.closest(interactiveSelector)) return;
ref.current?.focus({ preventScroll: true });
}
+9 -19
View File
@@ -18,6 +18,7 @@ import { useSessionSearch } from "@/hooks/domains/session/use-session-search";
import { useLazyLoadMessages } from "@/hooks/use-lazy-load-messages";
import { useAppStore } from "@/components/state-provider";
import type { Message } from "@/lib/types/http";
import { routePanelMouseDown } from "./chat/route-panel-mouse-down";
function useClarificationKey(agentMessageCount: number) {
const lastCountRef = useRef(agentMessageCount);
@@ -236,6 +237,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
height={clarificationHeight}
messages={pendingClarificationGroup}
onResolved={handleClarificationResolved}
shortcutScopeRef={panelRef}
/>
<ChatFooter
isArchived={isArchived}
@@ -262,6 +264,7 @@ type ClarificationSectionProps = {
height: number | null;
messages: readonly Message[] | null | undefined;
onResolved: () => void;
shortcutScopeRef: RefObject<HTMLElement | null>;
};
function ClarificationSection({
@@ -272,6 +275,7 @@ function ClarificationSection({
height,
messages,
onResolved,
shortcutScopeRef,
}: ClarificationSectionProps) {
if (!pendingClarification || isArchived) return null;
return (
@@ -283,7 +287,11 @@ function ClarificationSection({
className="px-1 overflow-y-scroll overscroll-contain max-h-[50vh]"
style={height === null ? undefined : { height }}
>
<ClarificationInputOverlay messages={messages} onResolved={onResolved} />
<ClarificationInputOverlay
messages={messages}
onResolved={onResolved}
shortcutScopeRef={shortcutScopeRef}
/>
</div>
</div>
);
@@ -340,21 +348,3 @@ function ChatFooter({
/>
);
}
// routePanelMouseDown routes non-interactive clicks to the panel root so that
// Ctrl+F can detect focus within the session panel. Extracted to keep
// TaskChatPanel's cyclomatic complexity within limits.
const interactiveSelector =
"input, textarea, select, button, a, [contenteditable], [tabindex]:not([tabindex='-1'])";
function routePanelMouseDown(
e: React.MouseEvent<HTMLDivElement>,
ref: React.RefObject<HTMLDivElement | null>,
): void {
const target = e.target as HTMLElement | null;
if (!target) return;
// Exclude `tabindex="-1"` so we don't match PanelRoot itself (which is
// marked focus-receivable but shouldn't short-circuit this handler).
if (target.closest(interactiveSelector)) return;
ref.current?.focus({ preventScroll: true });
}
@@ -21,6 +21,12 @@ function seedClarificationTask(
return seedClarificationSession(testPage, apiClient, seedData, title, { scenario });
}
const PLAN_WITH_CLARIFICATION_SCRIPT = [
'e2e:mcp:kandev:create_task_plan_kandev({"task_id":"{task_id}","content":"## Plan\\n\\nEdit 1 item","title":"Implementation Plan"})',
"e2e:delay(100)",
'e2e:mcp:kandev:ask_user_question_kandev({"questions":[{"id":"db","prompt":"Which database should we use?","options":[{"label":"PostgreSQL","description":"Relational"},{"label":"SQLite","description":"Embedded"}]},{"id":"language","prompt":"Which language should we use?","options":[{"label":"Go","description":"Compiled"},{"label":"TypeScript","description":"Web"}]},{"id":"deploy","prompt":"How should we deploy?","options":[{"label":"Docker","description":"Containerized"},{"label":"Bare metal","description":"Direct"}]}]})',
].join("\n");
// Exercises the regular task-create dialog (New Task in the sidebar); run with office off.
useRegularMode();
@@ -478,6 +484,7 @@ test.describe("Multi-question clarification carousel", () => {
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
// Press "1" → first option of the first question, auto-advance.
await session.chat.focus();
await testPage.keyboard.press("1");
await expect(session.clarificationStep(1)).toHaveAttribute("data-active", "true");
await expect(session.clarificationGroupProgress()).toContainText("1 of 3 answered");
@@ -497,6 +504,129 @@ test.describe("Multi-question clarification carousel", () => {
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
});
test("question shortcuts only fire while the chat panel has focus", async ({
testPage,
apiClient,
seedData,
}) => {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Clarification shortcuts stay in chat",
seedData.agentProfileId,
{
description: PLAN_WITH_CLARIFICATION_SCRIPT,
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
if (!(await session.planPanel.isVisible())) await session.togglePlanMode();
await expect(session.planEditor()).toBeVisible({ timeout: 15_000 });
const planEditor = session.planEditor();
await planEditor.click();
await testPage.keyboard.press("1");
await expect(planEditor).toContainText("Edit 1 item1");
await expect(session.clarificationStep(0)).toHaveAttribute("data-active", "true");
await expect(session.clarificationGroupProgress()).toContainText("0 of 3 answered");
await session.chat.focus();
await testPage.keyboard.press("1");
await expect(session.clarificationStep(1)).toHaveAttribute("data-active", "true");
await session.clarificationOption("Go").click();
await session.clarificationOption("Docker").click();
await expect(session.clarificationGroupProgress()).toContainText("3 of 3 answered");
await planEditor.focus();
await testPage.keyboard.press("ControlOrMeta+Enter");
await expect(session.clarificationOverlay()).toBeVisible();
await session.chat.focus();
await testPage.keyboard.press("ControlOrMeta+Enter");
await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 });
});
test("action tooltips show their keyboard shortcuts", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedClarificationTask(
testPage,
apiClient,
seedData,
"Clarification shortcut hints",
"clarification-multi",
);
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
await testPage.getByTestId("clarification-submit-shortcut").hover();
const submitTooltip = testPage.getByRole("tooltip", { name: /Submit answers/ });
await expect(submitTooltip).toContainText(/Ctrl|⌘/);
await expect(submitTooltip).toContainText("Enter");
await testPage.getByTestId("clarification-skip-shortcut").hover();
const skipTooltip = testPage.getByRole("tooltip", { name: /Skip all questions/ });
await expect(skipTooltip).toContainText("Esc");
await session.clarificationOption("PostgreSQL").click();
await session.clarificationPrev().hover();
const previousTooltip = testPage.getByRole("tooltip", { name: /Previous question/ });
await expect(previousTooltip).toContainText("←");
await session.clarificationNext().hover();
const nextTooltip = testPage.getByRole("tooltip", { name: /Next question/ });
await expect(nextTooltip).toContainText("→");
});
test("question shortcuts stay disabled while answers are submitting", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedClarificationTask(
testPage,
apiClient,
seedData,
"Clarification shortcuts while submitting",
"clarification-multi",
);
await expect(session.clarificationOverlay()).toBeVisible({ timeout: 30_000 });
await session.clarificationOption("PostgreSQL").click();
await session.clarificationOption("Go").click();
await session.clarificationOption("Docker").click();
let releaseResponse = () => undefined;
const heldResponse = new Promise<void>((resolve) => {
releaseResponse = resolve;
});
await testPage.route("**/api/v1/clarification/*/respond", async (route) => {
await heldResponse;
await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});
await session.chat.focus();
await testPage.keyboard.press("ControlOrMeta+Enter");
await expect(session.clarificationSubmit()).toContainText("Submitting");
await testPage.keyboard.press("ArrowLeft");
await expect(session.clarificationStep(2)).toHaveAttribute("data-active", "true");
await testPage.getByTestId("clarification-submit-shortcut").hover();
await expect(testPage.getByRole("tooltip", { name: /Submit answers/ })).not.toBeVisible();
releaseResponse();
await expect(session.clarificationOverlay()).not.toBeVisible({ timeout: 30_000 });
});
test("Esc skips the entire bundle from anywhere in the carousel", async ({
testPage,
apiClient,
@@ -96,6 +96,22 @@ async function waitForQuickChatWidth(dialog: Locator) {
}
test.describe("Quick Chat", () => {
test("clarification shortcuts work after clicking the message surface", async ({ testPage }) => {
const dialog = await openQuickChatWithAgent(testPage);
await sendQuickChatMessage(dialog, testPage, "/e2e:clarification-multi");
const clarification = dialog.getByTestId("clarification-overlay");
await expect(clarification).toBeVisible({ timeout: 30_000 });
await dialog.getByTestId("quick-chat-messages").click({ position: { x: 8, y: 8 } });
await expect(dialog.getByTestId("quick-chat-content")).toBeFocused();
await testPage.keyboard.press("1");
await expect(
clarification.locator('[data-testid="clarification-step"][data-step-index="1"]'),
).toHaveAttribute("data-active", "true");
});
test("offers configuration chat in setup and hides it once one exists", async ({
testPage,
apiClient,
+15 -7
View File
@@ -82,12 +82,12 @@ describe("isMac", () => {
});
describe("formatShortcut", () => {
it("formats Cmd+Enter on Mac", () => {
it("formats Command+Enter with the macOS symbol", () => {
const shortcut: KeyboardShortcut = {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
expect(formatShortcut(shortcut, "mac")).toBe("Cmd+Enter");
expect(formatShortcut(shortcut, "mac")).toBe("⌘+Enter");
});
it("formats Ctrl+Enter on Windows", () => {
@@ -114,20 +114,28 @@ describe("formatShortcut", () => {
expect(formatShortcut(shortcut, "windows")).toBe("Ctrl+S");
});
it("formats Cmd+S on Mac", () => {
it("formats Command+S with the macOS symbol", () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { cmd: true },
};
expect(formatShortcut(shortcut, "mac")).toBe("Cmd+S");
expect(formatShortcut(shortcut, "mac")).toBe("⌘+S");
});
it("formats Alt as Option on Mac", () => {
it("formats Alt with the macOS Option symbol", () => {
const shortcut: KeyboardShortcut = {
key: KEYS.A,
modifiers: { alt: true },
};
expect(formatShortcut(shortcut, "mac")).toBe("Option+A");
expect(formatShortcut(shortcut, "mac")).toBe("⌥+A");
});
it("formats every macOS modifier with its keyboard symbol", () => {
const shortcut: KeyboardShortcut = {
key: KEYS.S,
modifiers: { ctrl: true, cmd: true, alt: true, shift: true },
};
expect(formatShortcut(shortcut, "mac")).toBe("⌃+⌘+⌥+⇧+S");
});
it("formats Alt on Windows", () => {
@@ -181,7 +189,7 @@ describe("formatShortcut", () => {
key: KEYS.ENTER,
modifiers: { ctrlOrCmd: true },
};
expect(formatShortcut(shortcut)).toBe("Cmd+Enter");
expect(formatShortcut(shortcut)).toBe("⌘+Enter");
});
});
+6 -6
View File
@@ -46,13 +46,13 @@ function collectModifierNames(
const parts: string[] = [];
if (modifiers.ctrlOrCmd) {
parts.push(currentPlatform === "mac" ? "Cmd" : "Ctrl");
parts.push(currentPlatform === "mac" ? "⌘" : "Ctrl");
} else {
if (modifiers.ctrl) parts.push("Ctrl");
if (modifiers.cmd && currentPlatform === "mac") parts.push("Cmd");
if (modifiers.ctrl) parts.push(currentPlatform === "mac" ? "⌃" : "Ctrl");
if (modifiers.cmd && currentPlatform === "mac") parts.push("⌘");
}
if (modifiers.alt) parts.push(currentPlatform === "mac" ? "Option" : "Alt");
if (modifiers.shift) parts.push("Shift");
if (modifiers.alt) parts.push(currentPlatform === "mac" ? "⌥" : "Alt");
if (modifiers.shift) parts.push(currentPlatform === "mac" ? "⇧" : "Shift");
return parts;
}
@@ -60,7 +60,7 @@ function collectModifierNames(
* Format a keyboard shortcut for display based on platform
* @param shortcut - The keyboard shortcut definition
* @param platform - Optional platform override (defaults to current platform)
* @returns Formatted string like "Cmd+Enter" or "Ctrl+Enter"
* @returns Formatted string like "⌘+Enter" or "Ctrl+Enter"
*/
export function formatShortcut(shortcut: KeyboardShortcut, platform?: Platform): string {
const currentPlatform = platform ?? detectPlatform();