From 134020d51b58c8f28c61f313c4a2ceb60b284242 Mon Sep 17 00:00:00 2001 From: brownrw8 Date: Fri, 7 Feb 2025 17:29:54 -1000 Subject: [PATCH] Add a keyboard shortcut to switch between plan and act mode (#1626) * feat: add keyboard shortcut to Plan/Act toggle * remove Shift * fix: shortcut now Meta+Shift+a * ENG-123: Tooltip * feat: tooltip and platform detection * fix:impl suggestions * fix: add changeset * Fix style * remove platform detection - use metaKey detection and os utils * missed comma * Fixes * Fixes --------- Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> --- .changeset/tricky-rats-drum.md | 5 ++ src/core/webview/ClineProvider.ts | 3 +- src/shared/ExtensionMessage.ts | 5 ++ .../src/components/chat/ChatTextArea.tsx | 24 ++++-- webview-ui/src/components/common/Tooltip.tsx | 60 +++++++++++++ .../src/context/ExtensionStateContext.tsx | 3 +- webview-ui/src/utils/__tests__/hooks.spec.ts | 64 ++++++++++++++ .../src/utils/__tests__/platformUtils.spec.ts | 24 ++++++ webview-ui/src/utils/hooks.ts | 86 +++++++++++++++++++ webview-ui/src/utils/platformUtils.ts | 36 ++++++++ 10 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 .changeset/tricky-rats-drum.md create mode 100644 webview-ui/src/components/common/Tooltip.tsx create mode 100644 webview-ui/src/utils/__tests__/hooks.spec.ts create mode 100644 webview-ui/src/utils/__tests__/platformUtils.spec.ts create mode 100644 webview-ui/src/utils/hooks.ts create mode 100644 webview-ui/src/utils/platformUtils.ts diff --git a/.changeset/tricky-rats-drum.md b/.changeset/tricky-rats-drum.md new file mode 100644 index 0000000000..cabf8947e3 --- /dev/null +++ b/.changeset/tricky-rats-drum.md @@ -0,0 +1,5 @@ +--- +"claude-dev": minor +--- + +Feature: Added keyboard shortcut + tooltips for Plan/Act toggle diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 0f4758ff92..d55851d171 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -17,7 +17,7 @@ import { McpHub } from "../../services/mcp/McpHub" import { FirebaseAuthManager, UserInfo } from "../../services/auth/FirebaseAuthManager" import { ApiProvider, ModelInfo } from "../../shared/api" import { findLast } from "../../shared/array" -import { ExtensionMessage, ExtensionState } from "../../shared/ExtensionMessage" +import { ExtensionMessage, ExtensionState, Platform } from "../../shared/ExtensionMessage" import { HistoryItem } from "../../shared/HistoryItem" import { ClineCheckpointRestore, WebviewMessage } from "../../shared/WebviewMessage" import { fileExistsAtPath } from "../../utils/fs" @@ -1306,6 +1306,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { clineMessages: this.cline?.clineMessages || [], taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts), shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, + platform: process.platform as Platform, autoApprovalSettings, browserSettings, chatSettings, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 7f03c8e230..c08d611eda 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -48,6 +48,10 @@ export interface ExtensionMessage { mcpServers?: McpServer[] } +export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown" + +export const DEFAULT_PLATFORM = "unknown" + export interface ExtensionState { version: string apiConfiguration?: ApiConfiguration @@ -62,6 +66,7 @@ export interface ExtensionState { browserSettings: BrowserSettings chatSettings: ChatSettings isLoggedIn: boolean + platform: Platform userInfo?: { displayName: string | null email: string | null diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index fa8584f21f..baae8e682f 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -19,6 +19,9 @@ import Thumbnails from "../common/Thumbnails" import ApiOptions, { normalizeApiConfiguration } from "../settings/ApiOptions" import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" +import { useShortcut } from "../../utils/hooks" +import Tooltip from "../common/Tooltip" +import { useMetaKeyDetection } from "../../utils/hooks" interface ChatTextAreaProps { inputValue: string @@ -210,7 +213,7 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths, chatSettings, apiConfiguration, openRouterModels } = useExtensionState() + const { filePaths, chatSettings, apiConfiguration, openRouterModels, platform } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) @@ -232,6 +235,8 @@ const ChatTextArea = forwardRef( const [arrowPosition, setArrowPosition] = useState(0) const [menuPosition, setMenuPosition] = useState(0) + const [, metaKeyChar] = useMetaKeyDetection(platform) + // Add a ref to track previous menu state const prevShowModelSelector = useRef(showModelSelector) @@ -619,6 +624,8 @@ const ChatTextArea = forwardRef( }, changeModeDelay) }, [chatSettings.mode, showModelSelector, submitApiConfig]) + useShortcut("Meta+Shift+a", onModeToggle, { disableTextInputs: false }) // important that we don't disable the text input here + const handleContextButtonClick = useCallback(() => { if (textAreaDisabled) return @@ -1067,12 +1074,15 @@ const ChatTextArea = forwardRef( )} - - - - Plan - Act - + + + + Plan + Act + + ) diff --git a/webview-ui/src/components/common/Tooltip.tsx b/webview-ui/src/components/common/Tooltip.tsx new file mode 100644 index 0000000000..de3ee05908 --- /dev/null +++ b/webview-ui/src/components/common/Tooltip.tsx @@ -0,0 +1,60 @@ +import React, { useState } from "react" +import styled from "styled-components" +import { + getAsVar, + VSC_DESCRIPTION_FOREGROUND, + VSC_SIDEBAR_BACKGROUND, + VSC_INPUT_PLACEHOLDER_FOREGROUND, + VSC_INPUT_BORDER, +} from "../../utils/vscStyles" + +interface TooltipProps { + hintText: string + tipText: string + children: React.ReactNode +} + +// add styled component for tooltip +const TooltipBody = styled.div` + position: absolute; + background-color: ${getAsVar(VSC_SIDEBAR_BACKGROUND)}; + color: ${getAsVar(VSC_DESCRIPTION_FOREGROUND)}; + padding: 5px; + border-radius: 5px; + bottom: 100%; + left: -180%; + z-index: 10; + white-space: wrap; + max-width: 200px; + border: 1px solid ${getAsVar(VSC_INPUT_BORDER)}; + pointer-events: none; + font-size: 0.9em; +` + +const Hint = styled.div` + font-size: 0.8em; + color: ${getAsVar(VSC_INPUT_PLACEHOLDER_FOREGROUND)}; + opacity: 0.8; + margin-top: 2px; +` + +const Tooltip: React.FC = ({ tipText, hintText, children }) => { + const [visible, setVisible] = useState(false) + + const showTooltip = () => setVisible(true) + const hideTooltip = () => setVisible(false) + + return ( +
+ {children} + {visible && ( + + {tipText} + {hintText && {hintText}} + + )} +
+ ) +} + +export default Tooltip diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index a02b6f121a..deb840c1eb 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useCallback, useContext, useEffect, useState } from "react" import { useEvent } from "react-use" import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../../src/shared/AutoApprovalSettings" -import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage" +import { ExtensionMessage, ExtensionState, DEFAULT_PLATFORM } from "../../../src/shared/ExtensionMessage" import { ApiConfiguration, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../../src/shared/api" import { findLastIndex } from "../../../src/shared/array" import { McpServer } from "../../../src/shared/mcp" @@ -37,6 +37,7 @@ export const ExtensionStateContextProvider: React.FC<{ browserSettings: DEFAULT_BROWSER_SETTINGS, chatSettings: DEFAULT_CHAT_SETTINGS, isLoggedIn: false, + platform: DEFAULT_PLATFORM, }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false) diff --git a/webview-ui/src/utils/__tests__/hooks.spec.ts b/webview-ui/src/utils/__tests__/hooks.spec.ts new file mode 100644 index 0000000000..c613db72b3 --- /dev/null +++ b/webview-ui/src/utils/__tests__/hooks.spec.ts @@ -0,0 +1,64 @@ +import { renderHook } from "@testing-library/react" +import { useShortcut, useMetaKeyDetection } from "../hooks" +import { vi } from "vitest" + +describe("useShortcut", () => { + it("should call the callback when the shortcut is pressed", () => { + const callback = vi.fn() + renderHook(() => useShortcut("Meta+Shift+a", callback)) + + const event = new KeyboardEvent("keydown", { key: "a", metaKey: true, shiftKey: true }) + window.dispatchEvent(event) + + expect(callback).toHaveBeenCalled() + }) + + it("should not call the callback when the shortcut is not pressed", () => { + const callback = vi.fn() + renderHook(() => useShortcut("Command+Shift+b", callback)) + + const event = new KeyboardEvent("keydown", { key: "a", metaKey: true, shiftKey: true }) + window.dispatchEvent(event) + + expect(callback).not.toHaveBeenCalled() + }) + + it("should not call the callback when typing in a text input when disableTextInputs is true", () => { + const callback = vi.fn() + renderHook(() => useShortcut("Meta+Shift+a", callback, { disableTextInputs: true })) + + const input = document.createElement("input") + document.body.appendChild(input) + input.focus() + + const event = new KeyboardEvent("keydown", { key: "a", metaKey: true, shiftKey: true }) + input.dispatchEvent(event) + + expect(callback).not.toHaveBeenCalled() + + document.body.removeChild(input) + }) +}) + +describe("useMetaKeyDetection", () => { + it("should detect Windows OS and metaKey from platform", () => { + // mock the detect functions + const { result } = renderHook(() => useMetaKeyDetection("win32")) + expect(result.current[0]).toBe("windows") + expect(result.current[1]).toBe("⊞ Win") + }) + + it("should detect Mac OS and metaKey from platform", () => { + // mock the detect functions + const { result } = renderHook(() => useMetaKeyDetection("darwin")) + expect(result.current[0]).toBe("mac") + expect(result.current[1]).toBe("⌘ Command") + }) + + it("should detect Linux OS and metaKey from platform", () => { + // mock the detect functions + const { result } = renderHook(() => useMetaKeyDetection("linux")) + expect(result.current[0]).toBe("linux") + expect(result.current[1]).toBe("Alt") + }) +}) diff --git a/webview-ui/src/utils/__tests__/platformUtils.spec.ts b/webview-ui/src/utils/__tests__/platformUtils.spec.ts new file mode 100644 index 0000000000..9ec19ba3d0 --- /dev/null +++ b/webview-ui/src/utils/__tests__/platformUtils.spec.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from "vitest" +import { detectMetaKeyChar } from "../platformUtils" + +describe("detectMetaKeyChar", () => { + it("should return ⌘ Command for darwin platform", () => { + const result = detectMetaKeyChar("darwin") + expect(result).toBe("⌘ Command") + }) + + it("should return ⊞ Win for win32 platform", () => { + const result = detectMetaKeyChar("win32") + expect(result).toBe("⊞ Win") + }) + + it("should return Alt for linux platform", () => { + const result = detectMetaKeyChar("linux") + expect(result).toBe("Alt") + }) + + it("should return generic CMD for unknown platform", () => { + const result = detectMetaKeyChar("somethingelse") + expect(result).toBe("CMD") + }) +}) diff --git a/webview-ui/src/utils/hooks.ts b/webview-ui/src/utils/hooks.ts new file mode 100644 index 0000000000..18100c07e3 --- /dev/null +++ b/webview-ui/src/utils/hooks.ts @@ -0,0 +1,86 @@ +import { useCallback, useRef, useLayoutEffect, useState, useEffect } from "react" +import { detectMetaKeyChar, detectOS, unknown } from "./platformUtils" + +export const useMetaKeyDetection = (platform: string) => { + const [metaKeyChar, setMetaKeyChar] = useState(unknown) + const [os, setOs] = useState(unknown) + + useEffect(() => { + const detectedMetaKeyChar = detectMetaKeyChar(platform) + const detectedOs = detectOS(platform) + setMetaKeyChar(detectedMetaKeyChar) + setOs(detectedOs) + }, [platform]) + + return [os, metaKeyChar] +} + +export const useShortcut = (shortcut: string, callback: any, options = { disableTextInputs: true }) => { + const callbackRef = useRef(callback) + const [keyCombo, setKeyCombo] = useState([]) + + useLayoutEffect(() => { + callbackRef.current = callback + }) + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + const isTextInput = + event.target instanceof HTMLTextAreaElement || + (event.target instanceof HTMLInputElement && (!event.target.type || event.target.type === "text")) || + (event.target as HTMLElement).isContentEditable + + const modifierMap: { [key: string]: boolean } = { + Control: event.ctrlKey, + Alt: event.altKey, + Meta: event.metaKey, // alias for Command + Shift: event.shiftKey, + } + + if (event.repeat) { + return null + } + + if (options.disableTextInputs && isTextInput) { + return event.stopPropagation() + } + + if (shortcut.includes("+")) { + const keyArray = shortcut.split("+") + + if (Object.keys(modifierMap).includes(keyArray[0])) { + const finalKey = keyArray.pop() + + if (keyArray.every((k) => modifierMap[k]) && finalKey === event.key) { + return callbackRef.current(event) + } + } else { + if (keyArray[keyCombo.length] === event.key) { + if (keyArray[keyArray.length - 1] === event.key && keyCombo.length === keyArray.length - 1) { + callbackRef.current(event) + return setKeyCombo([]) + } + + return setKeyCombo((prevCombo) => [...prevCombo, event.key]) + } + if (keyCombo.length > 0) { + return setKeyCombo([]) + } + } + } + + if (shortcut === event.key) { + return callbackRef.current(event) + } + }, + [keyCombo.length, options.disableTextInputs, shortcut], + ) + + useEffect(() => { + window.addEventListener("keydown", handleKeyDown) + + return () => { + window.removeEventListener("keydown", handleKeyDown) + } + }, [handleKeyDown]) +} diff --git a/webview-ui/src/utils/platformUtils.ts b/webview-ui/src/utils/platformUtils.ts new file mode 100644 index 0000000000..6016268f13 --- /dev/null +++ b/webview-ui/src/utils/platformUtils.ts @@ -0,0 +1,36 @@ +export interface NavigatorUAData { + platform: string + brands: { brand: string; version: string }[] +} + +export const unknown = "Unknown" + +const platforms = { + windows: /win32/, + mac: /darwin/, + linux: /linux/, +} + +export const detectOS = (platform: string) => { + let detectedOs = unknown + if (platform.match(platforms.windows)) { + detectedOs = "windows" + } else if (platform.match(platforms.mac)) { + detectedOs = "mac" + } else if (platform.match(platforms.linux)) { + detectedOs = "linux" + } + return detectedOs +} + +export const detectMetaKeyChar = (platform: string) => { + if (platform.match(platforms.mac)) { + return "CMD" + } else if (platform.match(platforms.windows)) { + return "Win" + } else if (platform.match(platforms.linux)) { + return "Alt" + } else { + return "CMD" + } +}