diff --git a/.changeset/vscode-slash-command-actions.md b/.changeset/vscode-slash-command-actions.md new file mode 100644 index 000000000..e90969444 --- /dev/null +++ b/.changeset/vscode-slash-command-actions.md @@ -0,0 +1,5 @@ +--- +"@nanocollective/nanocoder": patch +--- + +Added slash command quick actions to the VS Code extension chat panel. Typing `/test`, `/explain`, or `/doc` in the input shows an autocomplete dropdown. Selecting a command injects a human-readable template into the textarea so the user can see and edit exactly what will be sent to the AI. diff --git a/plugins/vscode/media/chat-panel.html b/plugins/vscode/media/chat-panel.html index 712338b35..f5eb1673d 100644 --- a/plugins/vscode/media/chat-panel.html +++ b/plugins/vscode/media/chat-panel.html @@ -41,7 +41,7 @@
- +
@@ -102,11 +102,13 @@ caret: a textarea exposes no caret coordinates without a mirror element, and in a narrow sidebar full-width is more readable. --> +
+ diff --git a/plugins/vscode/media/chat-panel.js b/plugins/vscode/media/chat-panel.js index d25379c6a..c02538008 100644 --- a/plugins/vscode/media/chat-panel.js +++ b/plugins/vscode/media/chat-panel.js @@ -34,8 +34,95 @@ let pendingImages = []; let pendingUserMessageText = null; + // ── Slash command autocomplete state ──────────────────── + const slashDropdown = document.getElementById('slash-dropdown'); + const slashCommandUtils = globalThis.NanocoderSlashCommandUtils || (() => { + const commands = [ + { + name: '/test', + description: 'Write focused tests', + template: 'Write tests for the following:\n\n', + }, + { + name: '/explain', + description: 'Explain code or errors', + template: 'Explain the following clearly:\n\n', + }, + { + name: '/doc', + description: 'Draft documentation', + template: 'Write documentation for the following:\n\n', + }, + ]; + + function findToken(text, cursor, selectionEnd) { + if (typeof text !== 'string' || typeof cursor !== 'number') return null; + if (selectionEnd !== undefined && cursor !== selectionEnd) return null; + if (cursor < 0 || cursor > text.length) return null; + + const lineStart = text.lastIndexOf('\n', cursor - 1) + 1; + const beforeCursorOnLine = text.slice(lineStart, cursor); + const afterCursorOnLine = text.slice(cursor).split('\n', 1)[0]; + const match = beforeCursorOnLine.match(/^(\s*)\/([a-z-]*)$/i); + if (!match || /\S/.test(afterCursorOnLine)) return null; + + return { + start: lineStart + match[1].length, + end: cursor, + query: match[2].toLowerCase(), + }; + } + + function applyTemplate(text, cursor, selectionEnd, command) { + const token = findToken(text, cursor, selectionEnd); + if (!token || !command || typeof command.template !== 'string') return null; + + const existingText = (text.slice(0, token.start) + text.slice(token.end)).trim(); + return { + text: command.template + existingText, + cursor: command.template.length, + }; + } + + function isCommandName(value) { + if (typeof value !== 'string') return false; + const normalized = value.trim().toLowerCase(); + return commands.some(command => command.name === normalized); + } + + return { + SLASH_COMMANDS: commands, + findSlashCommandToken: findToken, + applySlashCommandTemplate: applyTemplate, + isSlashCommandName: isCommandName, + }; + })(); + const { + SLASH_COMMANDS, + findSlashCommandToken, + applySlashCommandTemplate, + isSlashCommandName, + } = slashCommandUtils; + + let slashSuggestions = []; + let slashSelectedIndex = 0; + let modelDropdown, modeDropdown, providerDropdown; + function removeSlashCommandChipsFromDom() { + if (!contextChipsContainer) return; + for (const chip of Array.from(contextChipsContainer.querySelectorAll('.context-chip'))) { + if (isSlashCommandName(chip.textContent.replace(/×$/, ''))) { + chip.remove(); + } + } + if (!contextChipsContainer.querySelector('.context-chip')) { + contextChipsContainer.classList.add('hidden'); + } + } + + removeSlashCommandChipsFromDom(); + function initDropdowns() { class CustomDropdown { constructor(triggerId, dropdownId, labelId, onChange) { @@ -712,14 +799,132 @@ }); } + // ── Slash command functions ────────────────────────────── + + function hideSlashDropdown() { + if (!slashDropdown) return; + slashDropdown.classList.add('hidden'); + slashDropdown.innerHTML = ''; + slashSuggestions = []; + slashSelectedIndex = 0; + chatInput.removeAttribute('aria-activedescendant'); + chatInput.setAttribute('aria-expanded', 'false'); + } + + function applySlashSelection(command) { + const result = applySlashCommandTemplate( + chatInput.value, + chatInput.selectionStart, + chatInput.selectionEnd, + command, + ); + if (!result) { + hideSlashDropdown(); + return; + } + chatInput.value = result.text; + chatInput.selectionStart = result.cursor; + chatInput.selectionEnd = result.cursor; + hideSlashDropdown(); + chatInput.dispatchEvent(new Event('input')); + chatInput.focus(); + } + + function renderSlashDropdown(commands) { + if (!slashDropdown) return; + slashDropdown.innerHTML = ''; + slashSuggestions = commands; + commands.forEach((command, index) => { + const item = document.createElement('button'); + item.type = 'button'; + item.id = 'slash-option-' + index; + item.setAttribute('role', 'option'); + item.setAttribute('aria-selected', index === slashSelectedIndex ? 'true' : 'false'); + item.className = 'w-full text-left bg-transparent border-none px-3 py-2 cursor-pointer transition-colors flex items-start justify-between gap-3'; + if (index === slashSelectedIndex) { + item.classList.add('bg-vscode-list-active', 'text-vscode-list-activeFg'); + chatInput.setAttribute('aria-activedescendant', item.id); + } else { + item.classList.add('hover:bg-vscode-list-hover', 'text-vscode-dropdown-foreground'); + } + const left = document.createElement('span'); + left.className = 'font-semibold text-[0.9em]'; + left.textContent = command.name; + const right = document.createElement('span'); + right.className = 'text-[0.8em] opacity-70'; + right.textContent = command.description; + item.appendChild(left); + item.appendChild(right); + item.addEventListener('click', (e) => { + e.stopPropagation(); + applySlashSelection(command); + }); + slashDropdown.appendChild(item); + }); + slashDropdown.classList.remove('hidden'); + chatInput.setAttribute('aria-expanded', 'true'); + } + + function updateSlashAutocomplete() { + if (!slashDropdown) { + hideSlashDropdown(); + return; + } + const token = findSlashCommandToken( + chatInput.value, + chatInput.selectionStart, + chatInput.selectionEnd, + ); + if (!token) { + hideSlashDropdown(); + return; + } + const filtered = SLASH_COMMANDS.filter(command => + command.name.slice(1).toLowerCase().startsWith(token.query) + ); + if (filtered.length === 0) { + hideSlashDropdown(); + return; + } + slashSelectedIndex = 0; + renderSlashDropdown(filtered); + } + // Auto-resize textarea chatInput.addEventListener('input', function () { this.style.height = 'auto'; this.style.height = (this.scrollHeight) + 'px'; + updateSlashAutocomplete(); }); // Handle Enter to submit (Shift+Enter for newline) chatInput.addEventListener('keydown', (e) => { + // Slash command navigation wins before mention navigation. + if (slashDropdown && !slashDropdown.classList.contains('hidden') && slashSuggestions.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + slashSelectedIndex = (slashSelectedIndex + 1) % slashSuggestions.length; + renderSlashDropdown(slashSuggestions); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + slashSelectedIndex = (slashSelectedIndex - 1 + slashSuggestions.length) % slashSuggestions.length; + renderSlashDropdown(slashSuggestions); + return; + } + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + applySlashSelection(slashSuggestions[slashSelectedIndex]); + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + hideSlashDropdown(); + return; + } + } + // Mention navigation has to win over Enter-to-submit. Handled at the top // of this same listener rather than in a second one, because two // listeners on the same element would race and Enter could submit the @@ -890,6 +1095,9 @@ function renderChips() { contextChipsContainer.innerHTML = ''; + attachedPaths = attachedPaths.filter( + item => !isSlashCommandName(item.path) && !isSlashCommandName(item.name), + ); if (attachedPaths.length === 0) { contextChipsContainer.classList.add('hidden'); return; @@ -1415,6 +1623,7 @@ } case 'pathInfoResolved': { const { path, name, kind } = message; + if (isSlashCommandName(path) || isSlashCommandName(name)) break; if (!attachedPaths.some(a => a.path === path)) { attachedPaths.push({ path, name, kind }); renderChips(); diff --git a/plugins/vscode/media/slash-command-utils.js b/plugins/vscode/media/slash-command-utils.js new file mode 100644 index 000000000..c61ec8b14 --- /dev/null +++ b/plugins/vscode/media/slash-command-utils.js @@ -0,0 +1,106 @@ +/** + * Pure helpers for the composer's slash-command autocomplete. + * + * Kept out of chat-panel.js so the command definitions and trigger rules can + * be tested directly without a VS Code webview. + */ +(function (root) { + 'use strict'; + + var SLASH_COMMANDS = [ + { + name: '/test', + description: 'Write focused tests', + template: 'Write tests for the following:\n\n', + }, + { + name: '/explain', + description: 'Explain code or errors', + template: 'Explain the following clearly:\n\n', + }, + { + name: '/doc', + description: 'Draft documentation', + template: 'Write documentation for the following:\n\n', + }, + ]; + + /** + * Find a slash-command token at the caret. + * + * Commands only count when they are the first non-whitespace text on their + * line. That keeps prose, URLs, and paths from opening the command menu just + * because they contain or end with a slash. + * + * @param {string} text Full textarea value. + * @param {number} cursor Caret offset (selectionStart). + * @param {number} selectionEnd Selection end offset. + * @returns {{start: number, end: number, query: string} | null} + */ + function findSlashCommandToken(text, cursor, selectionEnd) { + if (typeof text !== 'string' || typeof cursor !== 'number') { + return null; + } + if (selectionEnd !== undefined && cursor !== selectionEnd) { + return null; + } + if (cursor < 0 || cursor > text.length) { + return null; + } + + var lineStart = text.lastIndexOf('\n', cursor - 1) + 1; + var beforeCursorOnLine = text.slice(lineStart, cursor); + var afterCursorOnLine = text.slice(cursor).split('\n', 1)[0]; + var match = beforeCursorOnLine.match(/^(\s*)\/([a-z-]*)$/i); + if (!match || /\S/.test(afterCursorOnLine)) { + return null; + } + + return { + start: lineStart + match[1].length, + end: cursor, + query: match[2].toLowerCase(), + }; + } + + /** + * Replace the slash command token with visible template text. + * + * The returned text is exactly what the webview sends to the backend. + * + * @param {string} text Full textarea value. + * @param {number} cursor Caret offset. + * @param {number} selectionEnd Selection end offset. + * @param {{template: string}} command Selected slash command. + * @returns {{text: string, cursor: number} | null} + */ + function applySlashCommandTemplate(text, cursor, selectionEnd, command) { + var token = findSlashCommandToken(text, cursor, selectionEnd); + if (!token || !command || typeof command.template !== 'string') { + return null; + } + + var existingText = (text.slice(0, token.start) + text.slice(token.end)).trim(); + return { + text: command.template + existingText, + cursor: command.template.length, + }; + } + + function isSlashCommandName(value) { + if (typeof value !== 'string') { + return false; + } + var normalized = value.trim().toLowerCase(); + return SLASH_COMMANDS.some(function (command) { + return command.name === normalized; + }); + } + + root.NanocoderSlashCommandUtils = { + SLASH_COMMANDS: SLASH_COMMANDS, + findSlashCommandToken: findSlashCommandToken, + applySlashCommandTemplate: applySlashCommandTemplate, + isSlashCommandName: isSlashCommandName, + }; +})(typeof globalThis !== 'undefined' ? globalThis : this); diff --git a/plugins/vscode/src/chat-webview-provider.spec.ts b/plugins/vscode/src/chat-webview-provider.spec.ts new file mode 100644 index 000000000..96ff4d0a4 --- /dev/null +++ b/plugins/vscode/src/chat-webview-provider.spec.ts @@ -0,0 +1,99 @@ +import {readFileSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import vm from 'node:vm'; +import test from 'ava'; + +const slashCommandUtilsSource = readFileSync( + fileURLToPath(new URL('../media/slash-command-utils.js', import.meta.url)), + 'utf8', +); +const chatPanelScript = readFileSync( + fileURLToPath(new URL('../media/chat-panel.js', import.meta.url)), + 'utf8', +); +const chatWebviewProviderSource = readFileSync( + fileURLToPath(new URL('./chat-webview-provider.ts', import.meta.url)), + 'utf8', +); + +const sandbox: Record = {}; +vm.createContext(sandbox); +vm.runInContext(slashCommandUtilsSource, sandbox); + +const { + SLASH_COMMANDS, + findSlashCommandToken, + applySlashCommandTemplate, + isSlashCommandName, +} = sandbox.NanocoderSlashCommandUtils; + +test('slash commands - real command definitions expose user-visible templates', t => { + t.deepEqual( + SLASH_COMMANDS.map((command: {name: string; template: string}) => ({ + name: command.name, + template: command.template, + })), + [ + {name: '/test', template: 'Write tests for the following:\n\n'}, + {name: '/explain', template: 'Explain the following clearly:\n\n'}, + {name: '/doc', template: 'Write documentation for the following:\n\n'}, + ], + ); +}); + +test('slash commands - command token is only found as first text on a line', t => { + t.deepEqual(findSlashCommandToken('/ex', 3, 3), { + start: 0, + end: 3, + query: 'ex', + }); + t.deepEqual(findSlashCommandToken('code\n /te', 10, 10), { + start: 7, + end: 10, + query: 'te', + }); + t.is(findSlashCommandToken('explain this /te', 16, 16), null); + t.is(findSlashCommandToken('https://', 8, 8), null); + t.is(findSlashCommandToken('open https://', 13, 13), null); + t.is(findSlashCommandToken('/tmp/', 5, 5), null); +}); + +test('slash commands - command token ignores selections and trailing text', t => { + t.is(findSlashCommandToken('/test', 1, 4), null); + t.is(findSlashCommandToken('/test code', 5, 5), null); +}); + +test('slash commands - applying a command prepends visible template to existing text', t => { + const command = SLASH_COMMANDS.find((item: {name: string}) => item.name === '/explain'); + const result = applySlashCommandTemplate('code\n/explain', 13, 13, command); + + t.deepEqual(result, { + text: 'Explain the following clearly:\n\ncode', + cursor: 'Explain the following clearly:\n\n'.length, + }); +}); + +test('slash commands - command names are not treated as attachment chips', t => { + t.true(isSlashCommandName('/explain')); + t.true(isSlashCommandName(' /explain ')); + t.true(isSlashCommandName('/test')); + t.false(isSlashCommandName('/tmp')); + t.true(chatPanelScript.includes('if (isSlashCommandName(path) || isSlashCommandName(name)) break;')); + t.true(chatPanelScript.includes('!isSlashCommandName(item.path) && !isSlashCommandName(item.name)')); + t.true(chatPanelScript.includes('function removeSlashCommandChipsFromDom()')); + t.true(chatPanelScript.includes('removeSlashCommandChipsFromDom();')); +}); + +test('slash commands - no hidden prompt state remains in chat-panel runtime', t => { + t.true(chatPanelScript.includes('globalThis.NanocoderSlashCommandUtils ||')); + t.true(chatPanelScript.includes('applySlashCommandTemplate(')); + t.false(chatPanelScript.includes('selectedSlashCommand')); + t.false(chatPanelScript.includes('command: selectedSlashCommand')); + t.false(chatPanelScript.includes('_buildPrompt')); +}); + +test('webview assets include file mtime in cache key for extension dev mode', t => { + t.true(chatWebviewProviderSource.includes('fs.statSync(assetPath).mtimeMs')); + t.true(chatWebviewProviderSource.includes("assetVersion('chat-panel.js')")); + t.true(chatWebviewProviderSource.includes("assetVersion('slash-command-utils.js')")); +}); diff --git a/plugins/vscode/src/chat-webview-provider.ts b/plugins/vscode/src/chat-webview-provider.ts index 5c28224a0..ba293f2e0 100644 --- a/plugins/vscode/src/chat-webview-provider.ts +++ b/plugins/vscode/src/chat-webview-provider.ts @@ -483,10 +483,23 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { let html = fs.readFileSync(htmlPath, 'utf8'); const extVersion = vscode.extensions.getExtension('nanocollective.nanocoder')?.packageJSON.version || Date.now().toString(); - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'chat-panel.js')).with({ query: `v=${extVersion}` }); - const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'chat-panel.css')).with({ query: `v=${extVersion}` }); + const assetVersion = (fileName: string) => { + const assetPath = path.join(this._extensionUri.fsPath, 'media', fileName); + return `${extVersion}-${fs.statSync(assetPath).mtimeMs}`; + }; + const scriptUri = webview + .asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'chat-panel.js')) + .with({query: `v=${assetVersion('chat-panel.js')}`}); + const styleUri = webview + .asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'chat-panel.css')) + .with({query: `v=${assetVersion('chat-panel.css')}`}); const markedUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'marked.min.js')); - const mentionUtilsUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'mention-utils.js')).with({ query: `v=${extVersion}` }); + const mentionUtilsUri = webview + .asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'mention-utils.js')) + .with({query: `v=${assetVersion('mention-utils.js')}`}); + const slashCommandUtilsUri = webview + .asWebviewUri(vscode.Uri.joinPath(this._extensionUri, 'media', 'slash-command-utils.js')) + .with({query: `v=${assetVersion('slash-command-utils.js')}`}); const nonce = getNonce(); html = html.replace(/\{\{cspSource\}\}/g, webview.cspSource); @@ -495,6 +508,7 @@ export class ChatWebviewProvider implements vscode.WebviewViewProvider { html = html.replace(/\{\{scriptUri\}\}/g, scriptUri.toString()); html = html.replace(/\{\{markedUri\}\}/g, markedUri.toString()); html = html.replace(/\{\{mentionUtilsUri\}\}/g, mentionUtilsUri.toString()); + html = html.replace(/\{\{slashCommandUtilsUri\}\}/g, slashCommandUtilsUri.toString()); return html; }