Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/vscode-slash-command-actions.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion plugins/vscode/media/chat-panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
<div id="image-preview-container" class="flex flex-wrap gap-2 px-3 pt-3 empty:hidden"></div>
<!-- NEW: Attached context chips row -->
<div id="context-chips" class="hidden flex flex-wrap gap-1.5 px-3 pt-2 pb-1"></div>
<textarea id="chat-input" rows="1" placeholder="Ask Nanocoder anything..." class="w-full min-h-[44px] max-h-[250px] p-3 px-4 pb-2 bg-transparent text-vscode-input-fg font-vscode text-[0.95em] resize-none outline-none border-none" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="mention-dropdown"></textarea>
<textarea id="chat-input" rows="1" placeholder="Ask Nanocoder anything..." class="w-full min-h-[44px] max-h-[250px] p-3 px-4 pb-2 bg-transparent text-vscode-input-fg font-vscode text-[0.95em] resize-none outline-none border-none" role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="mention-dropdown slash-dropdown"></textarea>

<div class="flex items-center justify-between px-3 pb-2 pt-1 relative">
<div class="flex gap-1 items-center flex-1 min-w-0">
Expand Down Expand Up @@ -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. -->
<div id="mention-dropdown" class="hidden absolute bottom-[calc(100%+8px)] left-0 w-full max-h-64 overflow-y-auto bg-vscode-dropdown-bg border border-vscode-focusBorder rounded-lg shadow-xl z-50 flex-col font-vscode py-1" role="listbox" aria-label="Attach file or folder"></div>
<div id="slash-dropdown" class="hidden absolute bottom-[calc(100%+8px)] left-0 w-full max-h-64 overflow-y-auto bg-vscode-dropdown-bg border border-vscode-focusBorder rounded-lg shadow-xl z-50 flex-col font-vscode py-1" role="listbox" aria-label="Slash commands"></div>
</div>
</div>
<script nonce="{{nonce}}" src="{{markedUri}}"></script>
<!-- Pure @-mention helpers; must load before chat-panel.js reads them. -->
<script nonce="{{nonce}}" src="{{mentionUtilsUri}}"></script>
<script nonce="{{nonce}}" src="{{slashCommandUtilsUri}}"></script>
<script nonce="{{nonce}}" src="{{scriptUri}}"></script>
</body>
</html>
209 changes: 209 additions & 0 deletions plugins/vscode/media/chat-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
106 changes: 106 additions & 0 deletions plugins/vscode/media/slash-command-utils.js
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading