From 17157c46cbedb833f0215d3cce5cbaf7d962b28e Mon Sep 17 00:00:00 2001 From: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com> Date: Fri, 14 Feb 2025 18:48:47 -0800 Subject: [PATCH] Add terminal context mention --- src/core/mentions/index.ts | 12 +++++ .../terminal/get-latest-output.ts | 45 +++++++++++++++++++ src/shared/context-mentions.ts | 10 ++--- .../src/components/chat/ChatTextArea.tsx | 3 ++ .../src/components/chat/ContextMenu.tsx | 5 +++ webview-ui/src/utils/context-mentions.ts | 10 ++++- 6 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 src/integrations/terminal/get-latest-output.ts diff --git a/src/core/mentions/index.ts b/src/core/mentions/index.ts index cf5bdeaae0..5853a4abdd 100644 --- a/src/core/mentions/index.ts +++ b/src/core/mentions/index.ts @@ -8,6 +8,7 @@ import { extractTextFromFile } from "../../integrations/misc/extract-text" import { isBinaryFile } from "isbinaryfile" import { diagnosticsToProblemsString } from "../../integrations/diagnostics" import { getCommitInfo, getWorkingState } from "../../utils/git" +import { getLatestTerminalOutput } from "../../integrations/terminal/get-latest-output" export async function openMention(mention?: string): Promise { if (!mention) { @@ -29,6 +30,8 @@ export async function openMention(mention?: string): Promise { } } else if (mention === "problems") { vscode.commands.executeCommand("workbench.actions.view.problems") + } else if (mention === "terminal") { + vscode.commands.executeCommand("workbench.action.terminal.focus") } else if (mention.startsWith("http")) { vscode.env.openExternal(vscode.Uri.parse(mention)) } @@ -51,6 +54,8 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher return `Working directory changes (see below for details)` } else if (/^[a-f0-9]{7,40}$/.test(mention)) { return `Git commit '${mention}' (see below for commit info)` + } else if (mention === "terminal") { + return `Terminal Output (see below for output)` } return match }) @@ -118,6 +123,13 @@ export async function parseMentions(text: string, cwd: string, urlContentFetcher } catch (error) { parsedText += `\n\n\nError fetching commit info: ${error.message}\n` } + } else if (mention === "terminal") { + try { + const terminalOutput = await getLatestTerminalOutput() + parsedText += `\n\n\n${terminalOutput}\n` + } catch (error) { + parsedText += `\n\n\nError fetching terminal output: ${error.message}\n` + } } } diff --git a/src/integrations/terminal/get-latest-output.ts b/src/integrations/terminal/get-latest-output.ts new file mode 100644 index 0000000000..0c869e7fad --- /dev/null +++ b/src/integrations/terminal/get-latest-output.ts @@ -0,0 +1,45 @@ +import * as vscode from "vscode" + +/** + * Gets the contents of the active terminal + * @returns The terminal contents as a string + */ +export async function getLatestTerminalOutput(): Promise { + // Store original clipboard content to restore later + const originalClipboard = await vscode.env.clipboard.readText() + + try { + // Select terminal content + await vscode.commands.executeCommand("workbench.action.terminal.selectAll") + + // Copy selection to clipboard + await vscode.commands.executeCommand("workbench.action.terminal.copySelection") + + // Clear the selection + await vscode.commands.executeCommand("workbench.action.terminal.clearSelection") + + // Get terminal contents from clipboard + let terminalContents = (await vscode.env.clipboard.readText()).trim() + + // Check if there's actually a terminal open + if (terminalContents === originalClipboard) { + return "" + } + + // Clean up command separation + const lines = terminalContents.split("\n") + const lastLine = lines.pop()?.trim() + if (lastLine) { + let i = lines.length - 1 + while (i >= 0 && !lines[i].trim().startsWith(lastLine)) { + i-- + } + terminalContents = lines.slice(Math.max(i, 0)).join("\n") + } + + return terminalContents + } finally { + // Restore original clipboard content + await vscode.env.clipboard.writeText(originalClipboard) + } +} diff --git a/src/shared/context-mentions.ts b/src/shared/context-mentions.ts index 10375ff23e..915114ab93 100644 --- a/src/shared/context-mentions.ts +++ b/src/shared/context-mentions.ts @@ -26,10 +26,9 @@ Mention regex: - **Exact Word ('problems')**: Matches the exact word 'problems'. - **Word Boundary (`\b`)**: Ensures that 'problems' is matched as a whole word and not as part of another word (e.g., 'problematic'). - `|`: Logical OR. - - `problems\b`: - - **Exact Word ('git-changes')**: Matches the exact word 'git-changes'. - - **Word Boundary (`\b`)**: Ensures that 'git-changes' is matched as a whole word and not as part of another word. - + - `terminal\b`: + - **Exact Word ('terminal')**: Matches the exact word 'terminal'. + - **Word Boundary (`\b`)**: Ensures that 'terminal' is matched as a whole word and not as part of another word (e.g., 'terminals'). - `(?=[.,;:!?]?(?=[\s\r\n]|$))`: - **Positive Lookahead (`(?=...)`)**: Ensures that the match is followed by specific patterns without including them in the match. - `[.,;:!?]?`: @@ -43,6 +42,7 @@ Mention regex: - URLs that start with a protocol (like 'http://') followed by any non-whitespace characters (including query parameters). - The exact word 'problems'. - The exact word 'git-changes'. + - The exact word 'terminal'. - It ensures that any trailing punctuation marks (such as ',', '.', '!', etc.) are not included in the matched mention, allowing the punctuation to follow the mention naturally in the text. - **Global Regex**: @@ -50,7 +50,7 @@ Mention regex: */ export const mentionRegex = - /@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/ + /@((?:\/|\w+:\/\/)[^\s]+?|[a-f0-9]{7,40}\b|problems\b|git-changes\b|terminal\b)(?=[.,;:!?]?(?=[\s\r\n]|$))/ export const mentionRegexGlobal = new RegExp(mentionRegex.source, "g") export interface MentionSuggestion { diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 98d57965b7..d317efb96e 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -137,6 +137,7 @@ const ChatTextArea = forwardRef( const queryItems = useMemo(() => { return [ { type: ContextMenuOptionType.Problems, value: "problems" }, + { type: ContextMenuOptionType.Terminal, value: "terminal" }, ...gitCommits, ...openedTabs .filter((tab) => tab.path) @@ -214,6 +215,8 @@ const ChatTextArea = forwardRef( insertValue = value || "" } else if (type === ContextMenuOptionType.Problems) { insertValue = "problems" + } else if (type === ContextMenuOptionType.Terminal) { + insertValue = "terminal" } else if (type === ContextMenuOptionType.Git) { insertValue = value || "" } diff --git a/webview-ui/src/components/chat/ContextMenu.tsx b/webview-ui/src/components/chat/ContextMenu.tsx index bd631a98d5..2bb7a8ee68 100644 --- a/webview-ui/src/components/chat/ContextMenu.tsx +++ b/webview-ui/src/components/chat/ContextMenu.tsx @@ -70,6 +70,8 @@ const ContextMenu: React.FC = ({ ) case ContextMenuOptionType.Problems: return Problems + case ContextMenuOptionType.Terminal: + return Terminal case ContextMenuOptionType.URL: return Paste URL to fetch contents case ContextMenuOptionType.NoResults: @@ -133,6 +135,8 @@ const ContextMenu: React.FC = ({ return "folder" case ContextMenuOptionType.Problems: return "warning" + case ContextMenuOptionType.Terminal: + return "terminal" case ContextMenuOptionType.URL: return "link" case ContextMenuOptionType.Git: @@ -221,6 +225,7 @@ const ContextMenu: React.FC = ({ /> )} {(option.type === ContextMenuOptionType.Problems || + option.type === ContextMenuOptionType.Terminal || ((option.type === ContextMenuOptionType.File || option.type === ContextMenuOptionType.Folder || option.type === ContextMenuOptionType.OpenedFile || diff --git a/webview-ui/src/utils/context-mentions.ts b/webview-ui/src/utils/context-mentions.ts index 0fb57071ab..600a2f760e 100644 --- a/webview-ui/src/utils/context-mentions.ts +++ b/webview-ui/src/utils/context-mentions.ts @@ -61,6 +61,7 @@ export enum ContextMenuOptionType { File = "file", Folder = "folder", Problems = "problems", + Terminal = "terminal", URL = "url", Git = "git", NoResults = "noResults", @@ -151,6 +152,7 @@ export function getContextMenuOptions( return [ { type: ContextMenuOptionType.Problems }, + { type: ContextMenuOptionType.Terminal }, { type: ContextMenuOptionType.URL }, { type: ContextMenuOptionType.Folder }, { type: ContextMenuOptionType.File }, @@ -175,6 +177,9 @@ export function getContextMenuOptions( if ("problems".startsWith(lowerQuery)) { suggestions.push({ type: ContextMenuOptionType.Problems }) } + if ("terminal".startsWith(lowerQuery)) { + suggestions.push({ type: ContextMenuOptionType.Terminal }) + } if (query.startsWith("http")) { suggestions.push({ type: ContextMenuOptionType.URL, value: query }) } @@ -266,8 +271,9 @@ export function shouldShowContextMenu(text: string, position: number): boolean { // Don't show the menu if it's a URL if (textAfterAt.toLowerCase().startsWith("http")) return false - // Don't show the menu if it's a problems - if (textAfterAt.toLowerCase().startsWith("problems")) return false + // Don't show the menu if it's a problems or terminal + if (textAfterAt.toLowerCase().startsWith("problems") || textAfterAt.toLowerCase().startsWith("terminal")) + return false // NOTE: it's okay that menu shows when there's trailing punctuation since user could be inputting a path with marks