Skip to content

Commit 134020d

Browse files
Add a keyboard shortcut to switch between plan and act mode (RooCodeInc#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 <[email protected]>
1 parent 19c56c6 commit 134020d

File tree

10 files changed

+301
-9
lines changed

10 files changed

+301
-9
lines changed

.changeset/tricky-rats-drum.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"claude-dev": minor
3+
---
4+
5+
Feature: Added keyboard shortcut + tooltips for Plan/Act toggle

src/core/webview/ClineProvider.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { McpHub } from "../../services/mcp/McpHub"
1717
import { FirebaseAuthManager, UserInfo } from "../../services/auth/FirebaseAuthManager"
1818
import { ApiProvider, ModelInfo } from "../../shared/api"
1919
import { findLast } from "../../shared/array"
20-
import { ExtensionMessage, ExtensionState } from "../../shared/ExtensionMessage"
20+
import { ExtensionMessage, ExtensionState, Platform } from "../../shared/ExtensionMessage"
2121
import { HistoryItem } from "../../shared/HistoryItem"
2222
import { ClineCheckpointRestore, WebviewMessage } from "../../shared/WebviewMessage"
2323
import { fileExistsAtPath } from "../../utils/fs"
@@ -1306,6 +1306,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
13061306
clineMessages: this.cline?.clineMessages || [],
13071307
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
13081308
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
1309+
platform: process.platform as Platform,
13091310
autoApprovalSettings,
13101311
browserSettings,
13111312
chatSettings,

src/shared/ExtensionMessage.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ export interface ExtensionMessage {
4848
mcpServers?: McpServer[]
4949
}
5050

51+
export type Platform = "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win32" | "unknown"
52+
53+
export const DEFAULT_PLATFORM = "unknown"
54+
5155
export interface ExtensionState {
5256
version: string
5357
apiConfiguration?: ApiConfiguration
@@ -62,6 +66,7 @@ export interface ExtensionState {
6266
browserSettings: BrowserSettings
6367
chatSettings: ChatSettings
6468
isLoggedIn: boolean
69+
platform: Platform
6570
userInfo?: {
6671
displayName: string | null
6772
email: string | null

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import Thumbnails from "../common/Thumbnails"
1919
import ApiOptions, { normalizeApiConfiguration } from "../settings/ApiOptions"
2020
import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
2121
import ContextMenu from "./ContextMenu"
22+
import { useShortcut } from "../../utils/hooks"
23+
import Tooltip from "../common/Tooltip"
24+
import { useMetaKeyDetection } from "../../utils/hooks"
2225

2326
interface ChatTextAreaProps {
2427
inputValue: string
@@ -210,7 +213,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
210213
},
211214
ref,
212215
) => {
213-
const { filePaths, chatSettings, apiConfiguration, openRouterModels } = useExtensionState()
216+
const { filePaths, chatSettings, apiConfiguration, openRouterModels, platform } = useExtensionState()
214217
const [isTextAreaFocused, setIsTextAreaFocused] = useState(false)
215218
const [thumbnailsHeight, setThumbnailsHeight] = useState(0)
216219
const [textAreaBaseHeight, setTextAreaBaseHeight] = useState<number | undefined>(undefined)
@@ -232,6 +235,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
232235
const [arrowPosition, setArrowPosition] = useState(0)
233236
const [menuPosition, setMenuPosition] = useState(0)
234237

238+
const [, metaKeyChar] = useMetaKeyDetection(platform)
239+
235240
// Add a ref to track previous menu state
236241
const prevShowModelSelector = useRef(showModelSelector)
237242

@@ -619,6 +624,8 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
619624
}, changeModeDelay)
620625
}, [chatSettings.mode, showModelSelector, submitApiConfig])
621626

627+
useShortcut("Meta+Shift+a", onModeToggle, { disableTextInputs: false }) // important that we don't disable the text input here
628+
622629
const handleContextButtonClick = useCallback(() => {
623630
if (textAreaDisabled) return
624631

@@ -1067,12 +1074,15 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
10671074
)}
10681075
</ModelContainer>
10691076
</ButtonGroup>
1070-
1071-
<SwitchContainer data-testid="mode-switch" disabled={false} onClick={onModeToggle}>
1072-
<Slider isAct={chatSettings.mode === "act"} isPlan={chatSettings.mode === "plan"} />
1073-
<SwitchOption isActive={chatSettings.mode === "plan"}>Plan</SwitchOption>
1074-
<SwitchOption isActive={chatSettings.mode === "act"}>Act</SwitchOption>
1075-
</SwitchContainer>
1077+
<Tooltip
1078+
tipText={`In ${chatSettings.mode === "act" ? "Act" : "Plan"} mode, Cline will ${chatSettings.mode === "act" ? "complete the task immediately" : "gather information to architect a plan"}`}
1079+
hintText={`Toggle w/ ${metaKeyChar}+Shift+A`}>
1080+
<SwitchContainer data-testid="mode-switch" disabled={false} onClick={onModeToggle}>
1081+
<Slider isAct={chatSettings.mode === "act"} isPlan={chatSettings.mode === "plan"} />
1082+
<SwitchOption isActive={chatSettings.mode === "plan"}>Plan</SwitchOption>
1083+
<SwitchOption isActive={chatSettings.mode === "act"}>Act</SwitchOption>
1084+
</SwitchContainer>
1085+
</Tooltip>
10761086
</ControlsContainer>
10771087
</div>
10781088
)
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import React, { useState } from "react"
2+
import styled from "styled-components"
3+
import {
4+
getAsVar,
5+
VSC_DESCRIPTION_FOREGROUND,
6+
VSC_SIDEBAR_BACKGROUND,
7+
VSC_INPUT_PLACEHOLDER_FOREGROUND,
8+
VSC_INPUT_BORDER,
9+
} from "../../utils/vscStyles"
10+
11+
interface TooltipProps {
12+
hintText: string
13+
tipText: string
14+
children: React.ReactNode
15+
}
16+
17+
// add styled component for tooltip
18+
const TooltipBody = styled.div`
19+
position: absolute;
20+
background-color: ${getAsVar(VSC_SIDEBAR_BACKGROUND)};
21+
color: ${getAsVar(VSC_DESCRIPTION_FOREGROUND)};
22+
padding: 5px;
23+
border-radius: 5px;
24+
bottom: 100%;
25+
left: -180%;
26+
z-index: 10;
27+
white-space: wrap;
28+
max-width: 200px;
29+
border: 1px solid ${getAsVar(VSC_INPUT_BORDER)};
30+
pointer-events: none;
31+
font-size: 0.9em;
32+
`
33+
34+
const Hint = styled.div`
35+
font-size: 0.8em;
36+
color: ${getAsVar(VSC_INPUT_PLACEHOLDER_FOREGROUND)};
37+
opacity: 0.8;
38+
margin-top: 2px;
39+
`
40+
41+
const Tooltip: React.FC<TooltipProps> = ({ tipText, hintText, children }) => {
42+
const [visible, setVisible] = useState(false)
43+
44+
const showTooltip = () => setVisible(true)
45+
const hideTooltip = () => setVisible(false)
46+
47+
return (
48+
<div style={{ position: "relative", display: "inline-block" }} onMouseEnter={showTooltip} onMouseLeave={hideTooltip}>
49+
{children}
50+
{visible && (
51+
<TooltipBody>
52+
{tipText}
53+
{hintText && <Hint>{hintText}</Hint>}
54+
</TooltipBody>
55+
)}
56+
</div>
57+
)
58+
}
59+
60+
export default Tooltip

webview-ui/src/context/ExtensionStateContext.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { createContext, useCallback, useContext, useEffect, useState } from "react"
22
import { useEvent } from "react-use"
33
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../../src/shared/AutoApprovalSettings"
4-
import { ExtensionMessage, ExtensionState } from "../../../src/shared/ExtensionMessage"
4+
import { ExtensionMessage, ExtensionState, DEFAULT_PLATFORM } from "../../../src/shared/ExtensionMessage"
55
import { ApiConfiguration, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../../src/shared/api"
66
import { findLastIndex } from "../../../src/shared/array"
77
import { McpServer } from "../../../src/shared/mcp"
@@ -37,6 +37,7 @@ export const ExtensionStateContextProvider: React.FC<{
3737
browserSettings: DEFAULT_BROWSER_SETTINGS,
3838
chatSettings: DEFAULT_CHAT_SETTINGS,
3939
isLoggedIn: false,
40+
platform: DEFAULT_PLATFORM,
4041
})
4142
const [didHydrateState, setDidHydrateState] = useState(false)
4243
const [showWelcome, setShowWelcome] = useState(false)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { renderHook } from "@testing-library/react"
2+
import { useShortcut, useMetaKeyDetection } from "../hooks"
3+
import { vi } from "vitest"
4+
5+
describe("useShortcut", () => {
6+
it("should call the callback when the shortcut is pressed", () => {
7+
const callback = vi.fn()
8+
renderHook(() => useShortcut("Meta+Shift+a", callback))
9+
10+
const event = new KeyboardEvent("keydown", { key: "a", metaKey: true, shiftKey: true })
11+
window.dispatchEvent(event)
12+
13+
expect(callback).toHaveBeenCalled()
14+
})
15+
16+
it("should not call the callback when the shortcut is not pressed", () => {
17+
const callback = vi.fn()
18+
renderHook(() => useShortcut("Command+Shift+b", callback))
19+
20+
const event = new KeyboardEvent("keydown", { key: "a", metaKey: true, shiftKey: true })
21+
window.dispatchEvent(event)
22+
23+
expect(callback).not.toHaveBeenCalled()
24+
})
25+
26+
it("should not call the callback when typing in a text input when disableTextInputs is true", () => {
27+
const callback = vi.fn()
28+
renderHook(() => useShortcut("Meta+Shift+a", callback, { disableTextInputs: true }))
29+
30+
const input = document.createElement("input")
31+
document.body.appendChild(input)
32+
input.focus()
33+
34+
const event = new KeyboardEvent("keydown", { key: "a", metaKey: true, shiftKey: true })
35+
input.dispatchEvent(event)
36+
37+
expect(callback).not.toHaveBeenCalled()
38+
39+
document.body.removeChild(input)
40+
})
41+
})
42+
43+
describe("useMetaKeyDetection", () => {
44+
it("should detect Windows OS and metaKey from platform", () => {
45+
// mock the detect functions
46+
const { result } = renderHook(() => useMetaKeyDetection("win32"))
47+
expect(result.current[0]).toBe("windows")
48+
expect(result.current[1]).toBe("⊞ Win")
49+
})
50+
51+
it("should detect Mac OS and metaKey from platform", () => {
52+
// mock the detect functions
53+
const { result } = renderHook(() => useMetaKeyDetection("darwin"))
54+
expect(result.current[0]).toBe("mac")
55+
expect(result.current[1]).toBe("⌘ Command")
56+
})
57+
58+
it("should detect Linux OS and metaKey from platform", () => {
59+
// mock the detect functions
60+
const { result } = renderHook(() => useMetaKeyDetection("linux"))
61+
expect(result.current[0]).toBe("linux")
62+
expect(result.current[1]).toBe("Alt")
63+
})
64+
})
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, it, expect } from "vitest"
2+
import { detectMetaKeyChar } from "../platformUtils"
3+
4+
describe("detectMetaKeyChar", () => {
5+
it("should return ⌘ Command for darwin platform", () => {
6+
const result = detectMetaKeyChar("darwin")
7+
expect(result).toBe("⌘ Command")
8+
})
9+
10+
it("should return ⊞ Win for win32 platform", () => {
11+
const result = detectMetaKeyChar("win32")
12+
expect(result).toBe("⊞ Win")
13+
})
14+
15+
it("should return Alt for linux platform", () => {
16+
const result = detectMetaKeyChar("linux")
17+
expect(result).toBe("Alt")
18+
})
19+
20+
it("should return generic CMD for unknown platform", () => {
21+
const result = detectMetaKeyChar("somethingelse")
22+
expect(result).toBe("CMD")
23+
})
24+
})

webview-ui/src/utils/hooks.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useCallback, useRef, useLayoutEffect, useState, useEffect } from "react"
2+
import { detectMetaKeyChar, detectOS, unknown } from "./platformUtils"
3+
4+
export const useMetaKeyDetection = (platform: string) => {
5+
const [metaKeyChar, setMetaKeyChar] = useState(unknown)
6+
const [os, setOs] = useState(unknown)
7+
8+
useEffect(() => {
9+
const detectedMetaKeyChar = detectMetaKeyChar(platform)
10+
const detectedOs = detectOS(platform)
11+
setMetaKeyChar(detectedMetaKeyChar)
12+
setOs(detectedOs)
13+
}, [platform])
14+
15+
return [os, metaKeyChar]
16+
}
17+
18+
export const useShortcut = (shortcut: string, callback: any, options = { disableTextInputs: true }) => {
19+
const callbackRef = useRef(callback)
20+
const [keyCombo, setKeyCombo] = useState<string[]>([])
21+
22+
useLayoutEffect(() => {
23+
callbackRef.current = callback
24+
})
25+
26+
const handleKeyDown = useCallback(
27+
(event: KeyboardEvent) => {
28+
const isTextInput =
29+
event.target instanceof HTMLTextAreaElement ||
30+
(event.target instanceof HTMLInputElement && (!event.target.type || event.target.type === "text")) ||
31+
(event.target as HTMLElement).isContentEditable
32+
33+
const modifierMap: { [key: string]: boolean } = {
34+
Control: event.ctrlKey,
35+
Alt: event.altKey,
36+
Meta: event.metaKey, // alias for Command
37+
Shift: event.shiftKey,
38+
}
39+
40+
if (event.repeat) {
41+
return null
42+
}
43+
44+
if (options.disableTextInputs && isTextInput) {
45+
return event.stopPropagation()
46+
}
47+
48+
if (shortcut.includes("+")) {
49+
const keyArray = shortcut.split("+")
50+
51+
if (Object.keys(modifierMap).includes(keyArray[0])) {
52+
const finalKey = keyArray.pop()
53+
54+
if (keyArray.every((k) => modifierMap[k]) && finalKey === event.key) {
55+
return callbackRef.current(event)
56+
}
57+
} else {
58+
if (keyArray[keyCombo.length] === event.key) {
59+
if (keyArray[keyArray.length - 1] === event.key && keyCombo.length === keyArray.length - 1) {
60+
callbackRef.current(event)
61+
return setKeyCombo([])
62+
}
63+
64+
return setKeyCombo((prevCombo) => [...prevCombo, event.key])
65+
}
66+
if (keyCombo.length > 0) {
67+
return setKeyCombo([])
68+
}
69+
}
70+
}
71+
72+
if (shortcut === event.key) {
73+
return callbackRef.current(event)
74+
}
75+
},
76+
[keyCombo.length, options.disableTextInputs, shortcut],
77+
)
78+
79+
useEffect(() => {
80+
window.addEventListener("keydown", handleKeyDown)
81+
82+
return () => {
83+
window.removeEventListener("keydown", handleKeyDown)
84+
}
85+
}, [handleKeyDown])
86+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export interface NavigatorUAData {
2+
platform: string
3+
brands: { brand: string; version: string }[]
4+
}
5+
6+
export const unknown = "Unknown"
7+
8+
const platforms = {
9+
windows: /win32/,
10+
mac: /darwin/,
11+
linux: /linux/,
12+
}
13+
14+
export const detectOS = (platform: string) => {
15+
let detectedOs = unknown
16+
if (platform.match(platforms.windows)) {
17+
detectedOs = "windows"
18+
} else if (platform.match(platforms.mac)) {
19+
detectedOs = "mac"
20+
} else if (platform.match(platforms.linux)) {
21+
detectedOs = "linux"
22+
}
23+
return detectedOs
24+
}
25+
26+
export const detectMetaKeyChar = (platform: string) => {
27+
if (platform.match(platforms.mac)) {
28+
return "CMD"
29+
} else if (platform.match(platforms.windows)) {
30+
return "Win"
31+
} else if (platform.match(platforms.linux)) {
32+
return "Alt"
33+
} else {
34+
return "CMD"
35+
}
36+
}

0 commit comments

Comments
 (0)