mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
19c56c6ec4
commit
134020d51b
10 changed files with 301 additions and 9 deletions
5
.changeset/tricky-rats-drum.md
Normal file
5
.changeset/tricky-rats-drum.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Feature: Added keyboard shortcut + tooltips for Plan/Act toggle
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
60
webview-ui/src/components/common/Tooltip.tsx
Normal file
60
webview-ui/src/components/common/Tooltip.tsx
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
64
webview-ui/src/utils/__tests__/hooks.spec.ts
Normal file
64
webview-ui/src/utils/__tests__/hooks.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
24
webview-ui/src/utils/__tests__/platformUtils.spec.ts
Normal file
24
webview-ui/src/utils/__tests__/platformUtils.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
86
webview-ui/src/utils/hooks.ts
Normal file
86
webview-ui/src/utils/hooks.ts
Normal 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])
|
||||
}
|
||||
36
webview-ui/src/utils/platformUtils.ts
Normal file
36
webview-ui/src/utils/platformUtils.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue