diff --git a/src/core/Cline.ts b/src/core/Cline.ts index bb275aba31..abdcb7a253 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -59,6 +59,7 @@ import CheckpointTracker from "../integrations/checkpoints/CheckpointTracker" import getFolderSize from "get-folder-size" import { BrowserSettings } from "../shared/BrowserSettings" import { ADVISOR_SYSTEM_PROMPT } from "./prompts/advisor" +import { ChatSettings } from "../shared/ChatSettings" const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) ?? path.join(os.homedir(), "Desktop") // may or may not exist but fs checking existence would immediately ask for permission which would be bad UX, need to come up with a better solution @@ -77,6 +78,7 @@ export class Cline { customInstructions?: string autoApprovalSettings: AutoApprovalSettings private browserSettings: BrowserSettings + private chatSettings: ChatSettings apiConversationHistory: Anthropic.MessageParam[] = [] clineMessages: ClineMessage[] = [] private askResponse?: ClineAskResponse @@ -97,6 +99,7 @@ export class Cline { private advisorProblem?: string // streaming + isWaitingForFirstChunk = false isStreaming = false private currentStreamingContentIndex = 0 private assistantMessageContent: AssistantMessageContent[] = [] @@ -114,6 +117,7 @@ export class Cline { apiConfiguration: ApiConfiguration, autoApprovalSettings: AutoApprovalSettings, browserSettings: BrowserSettings, + chatSettings: ChatSettings, customInstructions?: string, task?: string, images?: string[], @@ -128,6 +132,7 @@ export class Cline { this.customInstructions = customInstructions this.autoApprovalSettings = autoApprovalSettings this.browserSettings = browserSettings + this.chatSettings = chatSettings if (historyItem) { this.taskId = historyItem.id this.conversationHistoryDeletedRange = historyItem.conversationHistoryDeletedRange @@ -145,6 +150,10 @@ export class Cline { this.browserSession.browserSettings = browserSettings } + updateChatSettings(chatSettings: ChatSettings) { + this.chatSettings = chatSettings + } + // Storing task to disk for history private async ensureTaskDirectoryExists(): Promise { @@ -1202,6 +1211,7 @@ export class Cline { this.api.getModel().info.supportsComputerUse ?? false, mcpHub, this.browserSettings, + this.chatSettings, supportsConsultAdvisor, ) let settingsCustomInstructions = this.customInstructions?.trim() @@ -1322,8 +1332,10 @@ export class Cline { try { // awaiting first chunk to see if it will throw an error + this.isWaitingForFirstChunk = true const firstChunk = await iterator.next() yield firstChunk.value + this.isWaitingForFirstChunk = false } catch (error) { if (!this.didAutomaticallyRetryFailedApiRequest) { console.log("first chunk failed, waiting 1 second before retrying") diff --git a/src/core/prompts/system.ts b/src/core/prompts/system.ts index a0fe39e7e1..e2865a4e25 100644 --- a/src/core/prompts/system.ts +++ b/src/core/prompts/system.ts @@ -3,12 +3,14 @@ import os from "os" import osName from "os-name" import { McpHub } from "../../services/mcp/McpHub" import { BrowserSettings } from "../../shared/BrowserSettings" +import { ChatSettings } from "../../shared/ChatSettings" export const SYSTEM_PROMPT = async ( cwd: string, supportsComputerUse: boolean, mcpHub: McpHub, browserSettings: BrowserSettings, + chatSettings: ChatSettings, supportsConsultAdvisor: boolean, ) => `You are Cline, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 9474d2866d..903d1a4745 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -24,6 +24,7 @@ import { getNonce } from "./getNonce" import { getUri } from "./getUri" import { AutoApprovalSettings, DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings" import { BrowserSettings, DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings" +import { ChatSettings, DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings" /* https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts @@ -67,6 +68,7 @@ type GlobalStateKey = | "openRouterAdvisorModelInfo" | "autoApprovalSettings" | "browserSettings" + | "chatSettings" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -216,18 +218,30 @@ export class ClineProvider implements vscode.WebviewViewProvider { async initClineWithTask(task?: string, images?: string[]) { await this.clearTask() // ensures that an exising task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one - const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings } = await this.getState() - this.cline = new Cline(this, apiConfiguration, autoApprovalSettings, browserSettings, customInstructions, task, images) - } - - async initClineWithHistoryItem(historyItem: HistoryItem) { - await this.clearTask() - const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings } = await this.getState() + const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } = + await this.getState() this.cline = new Cline( this, apiConfiguration, autoApprovalSettings, browserSettings, + chatSettings, + customInstructions, + task, + images, + ) + } + + async initClineWithHistoryItem(historyItem: HistoryItem) { + await this.clearTask() + const { apiConfiguration, customInstructions, autoApprovalSettings, browserSettings, chatSettings } = + await this.getState() + this.cline = new Cline( + this, + apiConfiguration, + autoApprovalSettings, + browserSettings, + chatSettings, customInstructions, undefined, undefined, @@ -467,6 +481,15 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.postStateToWebview() } break + case "chatSettings": + if (message.chatSettings) { + await this.updateGlobalState("chatSettings", message.chatSettings) + if (this.cline) { + this.cline.updateChatSettings(message.chatSettings) + } + await this.postStateToWebview() + } + break // case "relaunchChromeDebugMode": // if (this.cline) { // this.cline.browserSession.relaunchChromeDebugMode() @@ -603,7 +626,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { console.error("Failed to abort task", error) } await pWaitFor( - () => this.cline === undefined || this.cline.isStreaming === false || this.cline.didFinishAbortingStream, + () => + this.cline === undefined || + this.cline.isStreaming === false || + this.cline.didFinishAbortingStream || + this.cline.isWaitingForFirstChunk, // if only first chunk is processed, then there's no need to wait for graceful abort (closes edits, browser, etc) { timeout: 3_000, }, @@ -956,6 +983,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { taskHistory, autoApprovalSettings, browserSettings, + chatSettings, } = await this.getState() return { version: this.context.extension?.packageJSON?.version ?? "", @@ -969,6 +997,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId, autoApprovalSettings, browserSettings, + chatSettings, } } @@ -1059,6 +1088,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { taskHistory, autoApprovalSettings, browserSettings, + chatSettings, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -1094,6 +1124,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("taskHistory") as Promise, this.getGlobalState("autoApprovalSettings") as Promise, this.getGlobalState("browserSettings") as Promise, + this.getGlobalState("chatSettings") as Promise, ]) let apiProvider: ApiProvider @@ -1147,6 +1178,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { taskHistory, autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS, + chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS, } } diff --git a/src/shared/ChatSettings.ts b/src/shared/ChatSettings.ts new file mode 100644 index 0000000000..5d0e48c264 --- /dev/null +++ b/src/shared/ChatSettings.ts @@ -0,0 +1,7 @@ +export interface ChatSettings { + mode: "code" | "chat" +} + +export const DEFAULT_CHAT_SETTINGS: ChatSettings = { + mode: "code", +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 56ed6e0a1f..d5388d0467 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -3,6 +3,7 @@ import { ApiConfiguration, ModelInfo } from "./api" import { AutoApprovalSettings } from "./AutoApprovalSettings" import { BrowserSettings } from "./BrowserSettings" +import { ChatSettings } from "./ChatSettings" import { HistoryItem } from "./HistoryItem" import { McpServer } from "./mcp" @@ -47,6 +48,7 @@ export interface ExtensionState { shouldShowAnnouncement: boolean autoApprovalSettings: AutoApprovalSettings browserSettings: BrowserSettings + chatSettings: ChatSettings } export interface ClineMessage { diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 461e9b57ee..b18738316b 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,6 +1,7 @@ import { ApiConfiguration } from "./api" import { AutoApprovalSettings } from "./AutoApprovalSettings" import { BrowserSettings } from "./BrowserSettings" +import { ChatSettings } from "./ChatSettings" export interface WebviewMessage { type: @@ -28,6 +29,7 @@ export interface WebviewMessage { | "restartMcpServer" | "autoApprovalSettings" | "browserSettings" + | "chatSettings" | "checkpointDiff" | "checkpointRestore" | "taskCompletionViewChanges" @@ -41,6 +43,7 @@ export interface WebviewMessage { number?: number autoApprovalSettings?: AutoApprovalSettings browserSettings?: BrowserSettings + chatSettings?: ChatSettings } export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 5cac05b399..2d4833a350 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -12,6 +12,10 @@ import { import { MAX_IMAGES_PER_MESSAGE } from "./ChatView" import ContextMenu from "./ContextMenu" import Thumbnails from "../common/Thumbnails" +import { VSCodeButton } from "@vscode/webview-ui-toolkit/react" +import styled from "styled-components" +import { useWindowSize } from "react-use" +import { vscode } from "../../utils/vscode" interface ChatTextAreaProps { inputValue: string @@ -26,6 +30,74 @@ interface ChatTextAreaProps { onHeightChange?: (height: number) => void } +const SwitchOption = styled.div<{ isActive: boolean }>` + padding: 2px 8px; + color: ${(props) => (props.isActive ? "var(--vscode-badge-foreground)" : "var(--vscode-input-foreground)")}; + z-index: 1; + transition: color 0.2s ease; + font-size: 12px; + width: 50%; + text-align: center; + + &:hover { + background-color: ${(props) => (!props.isActive ? "var(--vscode-toolbar-hoverBackground)" : "transparent")}; + } +` + +const SwitchContainer = styled.div<{ disabled: boolean }>` + display: flex; + align-items: center; + background-color: var(--vscode-editor-background); + border: 1px solid var(--vscode-input-border); + border-radius: 12px; + overflow: hidden; + position: absolute; + right: 15px; + cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")}; + opacity: ${(props) => (props.disabled ? 0.5 : 1)}; + transform: scale(0.85); + transform-origin: right center; + flex-shrink: 0; +` + +const Slider = styled.div<{ isChat: boolean }>` + position: absolute; + height: 100%; + width: 50%; + background-color: var(--vscode-badge-background); + transition: transform 0.2s ease; + transform: translateX(${(props) => (props.isChat ? "100%" : "0%")}); +` + +const ButtonContainer = styled.div` + display: flex; + align-items: center; + gap: 3px; + font-size: 10px; + white-space: nowrap; +` + +const ACTUAL_SWITCH_WIDTH = 90 +const SWITCH_WIDTH = ACTUAL_SWITCH_WIDTH * 0.85 // Account for the 0.85 scale transform +const CONTEXT_BUTTON_WIDTH = 60 +const IMAGES_BUTTON_WIDTH = 80 +const CONTAINER_PADDING = 30 // 15px left + 15px right +const TOTAL_WIDTH = SWITCH_WIDTH + 4 + CONTEXT_BUTTON_WIDTH + IMAGES_BUTTON_WIDTH + CONTAINER_PADDING + +const ControlsContainer = styled.div` + display: flex; + align-items: center; + margin-top: -3px; + position: relative; + padding: 0px 15px 5px 15px; +` + +const ButtonGroup = styled.div` + display: flex; + align-items: center; + gap: 4px; +` + const ChatTextArea = forwardRef( ( { @@ -42,7 +114,7 @@ const ChatTextArea = forwardRef( }, ref, ) => { - const { filePaths } = useExtensionState() + const { filePaths, chatSettings } = useExtensionState() const [isTextAreaFocused, setIsTextAreaFocused] = useState(false) const [thumbnailsHeight, setThumbnailsHeight] = useState(0) const [textAreaBaseHeight, setTextAreaBaseHeight] = useState(undefined) @@ -57,6 +129,8 @@ const ChatTextArea = forwardRef( const [justDeletedSpaceAfterMention, setJustDeletedSpaceAfterMention] = useState(false) const [intendedCursorPosition, setIntendedCursorPosition] = useState(null) const contextMenuContainerRef = useRef(null) + const { width: windowWidth } = useWindowSize() + const showButtonText = windowWidth - CONTAINER_PADDING > TOTAL_WIDTH - CONTAINER_PADDING const queryItems = useMemo(() => { return [ @@ -406,181 +480,280 @@ const ChatTextArea = forwardRef( [updateCursorPosition], ) + const onModeToggle = useCallback(() => { + if (textAreaDisabled) return + const newMode = chatSettings.mode === "chat" ? "code" : "chat" + vscode.postMessage({ + type: "chatSettings", + chatSettings: { + mode: newMode, + }, + }) + }, [chatSettings.mode, textAreaDisabled]) + + const handleContextButtonClick = useCallback(() => { + if (textAreaDisabled) return + + // Focus the textarea first + textAreaRef.current?.focus() + + // If input is empty, just insert @ + if (!inputValue.trim()) { + const event = { + target: { + value: "@", + selectionStart: 1, + }, + } as React.ChangeEvent + handleInputChange(event) + updateHighlights() + return + } + + // If input ends with space or is empty, just append @ + if (inputValue.endsWith(" ")) { + const event = { + target: { + value: inputValue + "@", + selectionStart: inputValue.length + 1, + }, + } as React.ChangeEvent + handleInputChange(event) + updateHighlights() + return + } + + // Otherwise add space then @ + const event = { + target: { + value: inputValue + " @", + selectionStart: inputValue.length + 2, + }, + } as React.ChangeEvent + handleInputChange(event) + updateHighlights() + }, [inputValue, textAreaDisabled, handleInputChange, updateHighlights]) + return ( -
- {showContextMenu && ( -
- -
- )} - {!isTextAreaFocused && ( -
- )} -
- { - if (typeof ref === "function") { - ref(el) - } else if (ref) { - ref.current = el - } - textAreaRef.current = el - }} - value={inputValue} - disabled={textAreaDisabled} - onChange={(e) => { - handleInputChange(e) - updateHighlights() - }} - onKeyDown={handleKeyDown} - onKeyUp={handleKeyUp} - onFocus={() => setIsTextAreaFocused(true)} - onBlur={handleBlur} - onPaste={handlePaste} - onSelect={updateCursorPosition} - onMouseUp={updateCursorPosition} - onHeightChange={(height) => { - if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { - setTextAreaBaseHeight(height) - } - onHeightChange?.(height) - }} - placeholder={placeholderText} - maxRows={10} - autoFocus={true} - style={{ - width: "100%", - boxSizing: "border-box", - backgroundColor: "transparent", - color: "var(--vscode-input-foreground)", - //border: "1px solid var(--vscode-input-border)", - borderRadius: 2, - fontFamily: "var(--vscode-font-family)", - fontSize: "var(--vscode-editor-font-size)", - lineHeight: "var(--vscode-editor-line-height)", - resize: "none", - overflowX: "hidden", - overflowY: "scroll", - scrollbarWidth: "none", - // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410) - // borderTop: "9px solid transparent", - borderLeft: 0, - borderRight: 0, - borderTop: 0, - borderBottom: `${thumbnailsHeight + 6}px solid transparent`, - borderColor: "transparent", - // borderRight: "54px solid transparent", - // borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead - // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused - // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)", - padding: "9px 49px 3px 9px", - cursor: textAreaDisabled ? "not-allowed" : undefined, - flex: 1, - zIndex: 1, - }} - onScroll={() => updateHighlights()} - /> - {selectedImages.length > 0 && ( - - )} +
+ {showContextMenu && ( +
+ +
+ )} + {!isTextAreaFocused && ( +
+ )} +
+ { + if (typeof ref === "function") { + ref(el) + } else if (ref) { + ref.current = el + } + textAreaRef.current = el + }} + value={inputValue} + disabled={textAreaDisabled} + onChange={(e) => { + handleInputChange(e) + updateHighlights() + }} + onKeyDown={handleKeyDown} + onKeyUp={handleKeyUp} + onFocus={() => setIsTextAreaFocused(true)} + onBlur={handleBlur} + onPaste={handlePaste} + onSelect={updateCursorPosition} + onMouseUp={updateCursorPosition} + onHeightChange={(height) => { + if (textAreaBaseHeight === undefined || height < textAreaBaseHeight) { + setTextAreaBaseHeight(height) + } + onHeightChange?.(height) + }} + placeholder={placeholderText} + maxRows={10} + autoFocus={true} + style={{ + width: "100%", + boxSizing: "border-box", + backgroundColor: "transparent", + color: "var(--vscode-input-foreground)", + //border: "1px solid var(--vscode-input-border)", + borderRadius: 2, + fontFamily: "var(--vscode-font-family)", + fontSize: "var(--vscode-editor-font-size)", + lineHeight: "var(--vscode-editor-line-height)", + resize: "none", + overflowX: "hidden", + overflowY: "scroll", + scrollbarWidth: "none", + // Since we have maxRows, when text is long enough it starts to overflow the bottom padding, appearing behind the thumbnails. To fix this, we use a transparent border to push the text up instead. (https://stackoverflow.com/questions/42631947/maintaining-a-padding-inside-of-text-area/52538410#52538410) + // borderTop: "9px solid transparent", + borderLeft: 0, + borderRight: 0, + borderTop: 0, + borderBottom: `${thumbnailsHeight + 6}px solid transparent`, + borderColor: "transparent", + // borderRight: "54px solid transparent", + // borderLeft: "9px solid transparent", // NOTE: react-textarea-autosize doesn't calculate correct height when using borderLeft/borderRight so we need to use horizontal padding instead + // Instead of using boxShadow, we use a div with a border to better replicate the behavior when the textarea is focused + // boxShadow: "0px 0px 0px 1px var(--vscode-input-border)", + padding: "9px 28px 3px 9px", + cursor: textAreaDisabled ? "not-allowed" : undefined, + flex: 1, + zIndex: 1, + }} + onScroll={() => updateHighlights()} + /> + {selectedImages.length > 0 && ( + + )}
+ {/*
{ + if (!shouldDisableImages) { + onSelectImages() + } + }} + style={{ + marginRight: 5.5, + fontSize: 16.5, + }} + /> */} +
{ + if (!textAreaDisabled) { + onSend() + } + }} + style={{ fontSize: 15 }}>
+
+
+
+ + + + + + @ + {showButtonText && Context} + + + + { if (!shouldDisableImages) { onSelectImages() } }} style={{ - marginRight: 5.5, - fontSize: 16.5, - }} - /> -
{ - if (!textAreaDisabled) { - onSend() - } - }} - style={{ fontSize: 15 }}>
-
-
+ padding: "0px 0px", + height: "20px", + opacity: shouldDisableImages ? 0.5 : 1, + cursor: shouldDisableImages ? "not-allowed" : undefined, + }}> + + + {showButtonText && Add images} + + + + + + + Code + Chat + +
) }, diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 70e879f497..c1894ba03c 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -20,11 +20,11 @@ import { vscode } from "../../utils/vscode" import HistoryPreview from "../history/HistoryPreview" import { normalizeApiConfiguration } from "../settings/ApiOptions" import Announcement from "./Announcement" +import AutoApproveMenu from "./AutoApproveMenu" import BrowserSessionRow from "./BrowserSessionRow" import ChatRow from "./ChatRow" import ChatTextArea from "./ChatTextArea" import TaskHeader from "./TaskHeader" -import AutoApproveMenu from "./AutoApproveMenu" interface ChatViewProps { isHidden: boolean @@ -670,7 +670,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie useEvent("wheel", handleWheel, window, { passive: true }) // passive improves scrolling performance const placeholderText = useMemo(() => { - const text = task ? "Type a message (@ to add context)..." : "Type your task here (@ to add context)..." + const text = task ? "Type a message..." : "Type your task here..." return text }, [task]) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index a0363209c3..425b35db88 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -15,6 +15,7 @@ import { McpServer } from "../../../src/shared/mcp" import { convertTextMateToHljs } from "../utils/textMateToHljs" import { vscode } from "../utils/vscode" import { DEFAULT_BROWSER_SETTINGS } from "../../../src/shared/BrowserSettings" +import { DEFAULT_CHAT_SETTINGS } from "../../../src/shared/ChatSettings" interface ExtensionStateContextType extends ExtensionState { didHydrateState: boolean @@ -40,6 +41,7 @@ export const ExtensionStateContextProvider: React.FC<{ shouldShowAnnouncement: false, autoApprovalSettings: DEFAULT_AUTO_APPROVAL_SETTINGS, browserSettings: DEFAULT_BROWSER_SETTINGS, + chatSettings: DEFAULT_CHAT_SETTINGS, }) const [didHydrateState, setDidHydrateState] = useState(false) const [showWelcome, setShowWelcome] = useState(false)