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>
This commit is contained in:
brownrw8 2025-02-07 17:29:54 -10:00 committed by GitHub
parent 19c56c6ec4
commit 134020d51b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 301 additions and 9 deletions

View file

@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Feature: Added keyboard shortcut + tooltips for Plan/Act toggle

View file

@ -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,

View file

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

View file

@ -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<HTMLTextAreaElement, ChatTextAreaProps>(
},
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<number | undefined>(undefined)
@ -232,6 +235,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
}, 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<HTMLTextAreaElement, ChatTextAreaProps>(
)}
</ModelContainer>
</ButtonGroup>
<SwitchContainer data-testid="mode-switch" disabled={false} onClick={onModeToggle}>
<Slider isAct={chatSettings.mode === "act"} isPlan={chatSettings.mode === "plan"} />
<SwitchOption isActive={chatSettings.mode === "plan"}>Plan</SwitchOption>
<SwitchOption isActive={chatSettings.mode === "act"}>Act</SwitchOption>
</SwitchContainer>
<Tooltip
tipText={`In ${chatSettings.mode === "act" ? "Act" : "Plan"} mode, Cline will ${chatSettings.mode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
hintText={`Toggle w/ ${metaKeyChar}+Shift+A`}>
<SwitchContainer data-testid="mode-switch" disabled={false} onClick={onModeToggle}>
<Slider isAct={chatSettings.mode === "act"} isPlan={chatSettings.mode === "plan"} />
<SwitchOption isActive={chatSettings.mode === "plan"}>Plan</SwitchOption>
<SwitchOption isActive={chatSettings.mode === "act"}>Act</SwitchOption>
</SwitchContainer>
</Tooltip>
</ControlsContainer>
</div>
)

View file

@ -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<TooltipProps> = ({ tipText, hintText, children }) => {
const [visible, setVisible] = useState(false)
const showTooltip = () => setVisible(true)
const hideTooltip = () => setVisible(false)
return (
<div style={{ position: "relative", display: "inline-block" }} onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
{children}
{visible && (
<TooltipBody>
{tipText}
{hintText && <Hint>{hintText}</Hint>}
</TooltipBody>
)}
</div>
)
}
export default Tooltip

View file

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

View file

@ -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")
})
})

View file

@ -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")
})
})

View file

@ -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<string[]>([])
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])
}

View file

@ -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"
}
}