diff --git a/extensions/vscode/package-lock.json b/extensions/vscode/package-lock.json index 8f5cad8a0d..c6db7f1035 100644 --- a/extensions/vscode/package-lock.json +++ b/extensions/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "continue", - "version": "0.9.247", + "version": "0.9.248", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "continue", - "version": "0.9.247", + "version": "0.9.248", "license": "Apache-2.0", "dependencies": { "@continuedev/fetch": "^1.0.3", @@ -48,6 +48,7 @@ "request": "^2.88.2", "socket.io-client": "^4.7.2", "strip-ansi": "^7.1.0", + "svg-builder": "^2.0.0", "systeminformation": "^5.22.10", "tailwindcss": "^3.3.2", "undici": "^6.2.0", @@ -12122,6 +12123,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-builder": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/svg-builder/-/svg-builder-2.0.0.tgz", + "integrity": "sha512-v89FptvyrOy1Gf9KPkIo2jxroCLEZLiGBRTSutL3pzqZe2xnCFltkCLnEjV8QOEJ99PzBa9NGPGEDP9l8MWIrw==" + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 6a416096cb..550ed35385 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -703,6 +703,7 @@ "request": "^2.88.2", "socket.io-client": "^4.7.2", "strip-ansi": "^7.1.0", + "svg-builder": "^2.0.0", "systeminformation": "^5.22.10", "tailwindcss": "^3.3.2", "undici": "^6.2.0", diff --git a/extensions/vscode/src/activation/InlineTipManager.ts b/extensions/vscode/src/activation/InlineTipManager.ts new file mode 100644 index 0000000000..0ea078c218 --- /dev/null +++ b/extensions/vscode/src/activation/InlineTipManager.ts @@ -0,0 +1,427 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { EXTENSION_NAME } from "core/control-plane/env"; +// @ts-ignore +import svgBuilder from "svg-builder"; +import * as vscode from "vscode"; + +import { getTheme } from "../util/getTheme"; +import { getMetaKeyLabel, getMetaKeyName } from "../util/util"; + +const SVG_CONFIG = { + stroke: "#999998", + strokeWidth: 1, + shortcutColor: "#999998", + filter: "drop-shadow(0 2px 2px rgba(0,0,0,0.2))", + radius: 4, + leftMargin: 40, + debounceDelay: 500, + chatLabel: "Chat", + chatShortcut: `${getMetaKeyLabel()}L`, + editLabel: "Edit", + editShortcut: `${getMetaKeyLabel()}I`, + + get fontSize() { + return Math.ceil( + (vscode.workspace.getConfiguration("editor").get("fontSize") ?? + 14) * 0.8, + ); + }, + get fontFamily() { + return ( + vscode.workspace.getConfiguration("editor").get("fontFamily") || + "helvetica" + ); + }, + get paddingY() { + return Math.ceil(this.fontSize * 0.4); + }, + get paddingX() { + return Math.ceil(this.fontSize * 0.8); + }, + get gap() { + return this.fontSize * 2.5; + }, + get tipWidth() { + return ( + this.editShortcutX + + this.getEstimatedTextWidth(this.editShortcut) + + this.paddingX + ); + }, + get tipHeight() { + return this.fontSize + this.paddingY * 2; + }, + get textY() { + return this.tipHeight / 2 + this.fontSize * 0.35; + }, + get chatLabelX() { + return this.paddingX; + }, + get chatShortcutX() { + return this.chatLabelX + this.getEstimatedTextWidth(this.chatLabel) + 4; + }, + get editLabelX() { + return this.chatShortcutX + this.gap; + }, + get editShortcutX() { + return this.editLabelX + this.getEstimatedTextWidth(this.editLabel) + 4; + }, + getEstimatedTextWidth(text: string): number { + return text.length * this.fontSize * 0.6; + }, +} as const; + +export class InlineTipManager { + private static instance: InlineTipManager; + + private readonly excludedURIPrefixes = ["output:", "vscode://inline-chat"]; + private readonly hideCommand = "continue.hideInlineTip"; + private svgTooltip: vscode.Uri | undefined = undefined; + + private debounceTimer: NodeJS.Timeout | undefined; + private lastActiveEditor?: vscode.TextEditor; + private theme = getTheme(); + private svgTooltipDecoration = this.createSvgTooltipDecoration(); + private emptyFileTooltipDecoration = this.createEmptyFileTooltipDecoration(); + + public static getInstance(): InlineTipManager { + if (!InlineTipManager.instance) { + InlineTipManager.instance = new InlineTipManager(); + } + return InlineTipManager.instance; + } + + private constructor() { + this.createSvgTooltip(); + this.setupSvgTipListeners(); + } + + public setupInlineTips(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.window.onDidChangeTextEditorSelection((e) => { + this.handleSelectionChange(e); + }), + ); + + this.setupEmptyFileTips(context); + + context.subscriptions.push(this); + } + + public handleSelectionChange(e: vscode.TextEditorSelectionChangeEvent) { + const selection = e.selections[0]; + const editor = e.textEditor; + + if (selection.isEmpty || !this.shouldRenderTip(editor.document.uri)) { + editor.setDecorations(this.svgTooltipDecoration, []); + return; + } + + this.debouncedSelectionChange(editor, selection); + } + + public dispose() { + this.svgTooltipDecoration.dispose(); + this.emptyFileTooltipDecoration.dispose(); + + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + } + + private debouncedSelectionChange( + editor: vscode.TextEditor, + selection: vscode.Selection, + ) { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + + this.debounceTimer = setTimeout(() => { + // Clear decoration from previous editor + if (this.lastActiveEditor && this.lastActiveEditor !== editor) { + this.lastActiveEditor.setDecorations(this.svgTooltipDecoration, []); + } + + this.lastActiveEditor = editor; + + this.updateTooltipPosition(editor, selection); + }, SVG_CONFIG.debounceDelay); + } + + private setupSvgTipListeners() { + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("workbench.colorTheme")) { + this.theme = getTheme(); + this.createSvgTooltip(); + } + }); + + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("editor.fontSize")) { + this.createSvgTooltip(); + } + }); + } + + private shouldRenderTip(uri: vscode.Uri): boolean { + const isAllowedUri = + !this.excludedURIPrefixes.some((prefix) => + uri.toString().startsWith(prefix), + ) && uri.scheme !== "comment"; + + const isEnabled = + !!vscode.workspace + .getConfiguration(EXTENSION_NAME) + .get("showInlineTip") === true; + + return isAllowedUri && isEnabled; + } + + private setupEmptyFileTips(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + if ( + editor?.document.getText() === "" && + this.shouldRenderTip(editor.document.uri) + ) { + editor.setDecorations(this.emptyFileTooltipDecoration, [ + { + range: new vscode.Range( + new vscode.Position(0, Number.MAX_VALUE), + new vscode.Position(0, Number.MAX_VALUE), + ), + }, + ]); + } + }), + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((e) => { + if ( + e.document.getText() === "" && + this.shouldRenderTip(e.document.uri) + ) { + vscode.window.visibleTextEditors.forEach((editor) => { + editor.setDecorations(this.emptyFileTooltipDecoration, [ + { + range: new vscode.Range( + new vscode.Position(0, Number.MAX_VALUE), + new vscode.Position(0, Number.MAX_VALUE), + ), + }, + ]); + }); + } else { + vscode.window.visibleTextEditors.forEach((editor) => { + editor.setDecorations(this.emptyFileTooltipDecoration, []); + }); + } + }), + ); + } + + private createEmptyFileTooltipDecoration() { + return vscode.window.createTextEditorDecorationType({ + after: { + contentText: `Use ${getMetaKeyName()} + I to generate code`, + color: "#888", + margin: "2em 0 0 0", + fontStyle: "italic", + }, + }); + } + + private createSvgTooltipDecoration() { + return vscode.window.createTextEditorDecorationType({ + after: { + contentIconPath: this.svgTooltip, + margin: `-5px 0 0 ${SVG_CONFIG.leftMargin}px`, + width: `${SVG_CONFIG.tipWidth}px`, + }, + }); + } + + private createSvgTooltip() { + const baseTextConfig = { + y: SVG_CONFIG.textY, + "font-family": SVG_CONFIG.fontFamily, + "font-size": SVG_CONFIG.fontSize, + }; + + if (!this.theme) { + return; + } + + try { + const svgContent = svgBuilder + .width(SVG_CONFIG.tipWidth) + .height(SVG_CONFIG.tipHeight) + // Main rectangle + .rect({ + x: 0, + y: 0, + width: SVG_CONFIG.tipWidth, + height: SVG_CONFIG.tipHeight, + rx: SVG_CONFIG.radius, + ry: SVG_CONFIG.radius, + fill: this.theme.colors["editor.background"], + stroke: SVG_CONFIG.stroke, + "stroke-width": SVG_CONFIG.strokeWidth, + filter: SVG_CONFIG.filter, + }) + // Chat + .text( + { + ...baseTextConfig, + x: SVG_CONFIG.chatLabelX, + fill: this.theme.colors["editor.foreground"], + }, + SVG_CONFIG.chatLabel, + ) + .text( + { + ...baseTextConfig, + x: SVG_CONFIG.chatShortcutX, + fill: SVG_CONFIG.shortcutColor, + }, + SVG_CONFIG.chatShortcut, + ) + // Edit + .text( + { + ...baseTextConfig, + x: SVG_CONFIG.editLabelX, + fill: this.theme.colors["editor.foreground"], + }, + SVG_CONFIG.editLabel, + ) + .text( + { + ...baseTextConfig, + x: SVG_CONFIG.editShortcutX, + fill: SVG_CONFIG.shortcutColor, + }, + SVG_CONFIG.editShortcut, + ) + .render(); + + const dataUri = `data:image/svg+xml;base64,${Buffer.from(svgContent).toString("base64")}`; + + this.svgTooltip = vscode.Uri.parse(dataUri); + this.svgTooltipDecoration.dispose(); + this.svgTooltipDecoration = this.createSvgTooltipDecoration(); + } catch (error) { + console.error("Error creating SVG for inline tip:", error); + } + } + + private buildHideTooltipHoverMsg() { + const hoverMarkdown = new vscode.MarkdownString( + `[Disable](command:${this.hideCommand})`, + ); + + hoverMarkdown.isTrusted = true; + hoverMarkdown.supportHtml = true; + return hoverMarkdown; + } + + /** + * Calculates tooltip position using these rules: + * 1. For single-line selection: Place after the line's content + * 2. For multi-line selection: Place after the longer line between: + * - The first non-empty selected line + * - The line above the selection + * Returns null if selection is empty or contains only empty lines + */ + private calculateTooltipPosition( + editor: vscode.TextEditor, + selection: vscode.Selection, + ): vscode.Position | null { + const document = editor.document; + + // Get selection info + const startLine = selection.start.line; + const endLine = selection.end.line; + const isFullLineSelection = + selection.start.character === 0 && + (selection.end.line > selection.start.line + ? selection.end.character === 0 + : selection.end.character === + document.lineAt(selection.end.line).text.length); + + // Helper functions + const isLineEmpty = (lineNumber: number): boolean => { + return document.lineAt(lineNumber).text.trim().length === 0; + }; + + const getLineEndChar = (lineNumber: number): number => { + return document.lineAt(lineNumber).text.trimEnd().length; + }; + + // If single empty line selected and not full line selection, return null + if ( + startLine === endLine && + isLineEmpty(startLine) && + !isFullLineSelection + ) { + return null; + } + + // Find topmost non-empty line + let topNonEmptyLine = startLine; + while (topNonEmptyLine <= endLine && isLineEmpty(topNonEmptyLine)) { + topNonEmptyLine++; + } + + // If all lines empty, return null + if (topNonEmptyLine > endLine) { + return null; + } + + const OFFSET = 4; // Characters to offset from end of line + + // Single line or full line selection + if (isFullLineSelection || startLine === endLine) { + return new vscode.Position( + topNonEmptyLine, + getLineEndChar(topNonEmptyLine) + OFFSET, + ); + } + + // Check line above selection + const lineAboveSelection = Math.max(0, startLine - 1); + + // Get end positions + const topNonEmptyEndChar = getLineEndChar(topNonEmptyLine); + const lineAboveEndChar = getLineEndChar(lineAboveSelection); + + const baseEndChar = Math.max(topNonEmptyEndChar, lineAboveEndChar); + + return new vscode.Position(topNonEmptyLine, baseEndChar + OFFSET); + } + + private updateTooltipPosition( + editor: vscode.TextEditor, + selection: vscode.Selection, + ) { + const position = this.calculateTooltipPosition(editor, selection); + + if (!position) { + editor.setDecorations(this.svgTooltipDecoration, []); + return; + } + + editor.setDecorations(this.svgTooltipDecoration, [ + { + range: new vscode.Range(position, position), + hoverMessage: [this.buildHideTooltipHoverMsg()], + }, + ]); + } +} + +export default function setupInlineTips(context: vscode.ExtensionContext) { + InlineTipManager.getInstance().setupInlineTips(context); +} diff --git a/extensions/vscode/src/activation/activate.ts b/extensions/vscode/src/activation/activate.ts index 7aa0ae4a96..1bd22c7f44 100644 --- a/extensions/vscode/src/activation/activate.ts +++ b/extensions/vscode/src/activation/activate.ts @@ -7,7 +7,7 @@ import registerQuickFixProvider from "../lang-server/codeActions"; import { getExtensionVersion } from "../util/util"; import { VsCodeContinueApi } from "./api"; -import { setupInlineTips } from "./inlineTips"; +import setupInlineTips from "./InlineTipManager"; export async function activateExtension(context: vscode.ExtensionContext) { // Add necessary files diff --git a/extensions/vscode/src/activation/inlineTips.ts b/extensions/vscode/src/activation/inlineTips.ts deleted file mode 100644 index 49a8adf298..0000000000 --- a/extensions/vscode/src/activation/inlineTips.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { EXTENSION_NAME } from "core/control-plane/env"; -import * as vscode from "vscode"; - -import { getMetaKeyName } from "../util/util"; - -const inlineTipDecoration = vscode.window.createTextEditorDecorationType({ - after: { - contentText: `Add to chat (${getMetaKeyName()}+L) | Edit highlighted code (${getMetaKeyName()}+I).`, - color: "#888", - margin: "0 0 0 6em", - fontWeight: "bold", - }, -}); - -function showInlineTip() { - return vscode.workspace - .getConfiguration(EXTENSION_NAME) - .get("showInlineTip"); -} - -function handleSelectionChange(e: vscode.TextEditorSelectionChangeEvent) { - const selection = e.selections[0]; - const editor = e.textEditor; - - if (editor.document.uri.toString().startsWith("output:")) { - return; - } - - if (selection.isEmpty || showInlineTip() === false) { - editor.setDecorations(inlineTipDecoration, []); - return; - } - - const line = Math.max(0, selection.start.line - 1); - - const hoverMarkdown = new vscode.MarkdownString( - `Click [here](command:continue.hideInlineTip) to hide these suggestions`, - ); - hoverMarkdown.isTrusted = true; - hoverMarkdown.supportHtml = true; - editor.setDecorations(inlineTipDecoration, [ - { - range: new vscode.Range( - new vscode.Position(line, Number.MAX_VALUE), - new vscode.Position(line, Number.MAX_VALUE), - ), - hoverMessage: [hoverMarkdown], - }, - ]); -} - -const emptyFileTooltipDecoration = vscode.window.createTextEditorDecorationType( - { - after: { - contentText: `Use ${getMetaKeyName()}+I to generate code`, - color: "#888", - margin: "2em 0 0 0", - fontStyle: "italic", - }, - }, -); - -let selectionChangeDebounceTimer: NodeJS.Timeout | undefined; - -export function setupInlineTips(context: vscode.ExtensionContext) { - context.subscriptions.push( - vscode.window.onDidChangeTextEditorSelection((e) => { - if (selectionChangeDebounceTimer) { - clearTimeout(selectionChangeDebounceTimer); - } - selectionChangeDebounceTimer = setTimeout(() => { - handleSelectionChange(e); - }, 200); - }), - ); - - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor((editor) => { - if (editor?.document.getText() === "" && showInlineTip() === true) { - if ( - editor.document.uri.toString().startsWith("output:") || - editor.document.uri.scheme === "comment" - ) { - return; - } - - editor.setDecorations(emptyFileTooltipDecoration, [ - { - range: new vscode.Range( - new vscode.Position(0, Number.MAX_VALUE), - new vscode.Position(0, Number.MAX_VALUE), - ), - }, - ]); - } - }), - ); - - context.subscriptions.push( - vscode.workspace.onDidChangeTextDocument((e) => { - if (e.document.uri.toString().startsWith("vscode://inline-chat")) { - return; - } - if (e.document.getText() === "" && showInlineTip() === true) { - vscode.window.visibleTextEditors.forEach((editor) => { - editor.setDecorations(emptyFileTooltipDecoration, [ - { - range: new vscode.Range( - new vscode.Position(0, Number.MAX_VALUE), - new vscode.Position(0, Number.MAX_VALUE), - ), - }, - ]); - }); - } else { - vscode.window.visibleTextEditors.forEach((editor) => { - editor.setDecorations(emptyFileTooltipDecoration, []); - }); - } - }), - ); -} diff --git a/gui/src/components/indexing/ChatIndexingPeeks.tsx b/gui/src/components/indexing/ChatIndexingPeeks.tsx index 1ec1076c01..66936597de 100644 --- a/gui/src/components/indexing/ChatIndexingPeeks.tsx +++ b/gui/src/components/indexing/ChatIndexingPeeks.tsx @@ -62,7 +62,7 @@ function ChatIndexingPeek({ state }: ChatIndexingPeekProps) { { dispatch( setIndexingChatPeekHidden({ type: state.type, hidden: true }),