import * as vscode from "vscode"; import type { SfClient, SlashCommand } from "./sf-client.js"; /** * CompletionItemProvider that surfaces SF slash commands when the user * types `/` at the start of a line (or after only whitespace) in Markdown, * plaintext, and TypeScript/JavaScript files. * * Commands are fetched from the running agent via get_commands RPC and * cached so the list remains available between keystrokes. */ export class SfSlashCompletionProvider implements vscode.CompletionItemProvider, vscode.Disposable { private cachedCommands: SlashCommand[] = []; private disposables: vscode.Disposable[] = []; constructor(private readonly client: SfClient) { // Refresh cache whenever the connection (re)establishes. this.disposables.push( client.onConnectionChange(async (connected) => { if (connected) { await this.refreshCache(); } else { this.cachedCommands = []; } }), ); } async provideCompletionItems( document: vscode.TextDocument, position: vscode.Position, _token: vscode.CancellationToken, ): Promise { const lineText = document.lineAt(position).text; const linePrefix = lineText.slice(0, position.character); // Only activate when the non-whitespace content starts with `/`. if (!/^\s*\/\S*$/.test(linePrefix)) { return undefined; } // Lazily populate the cache on first use. if (this.cachedCommands.length === 0 && this.client.isConnected) { await this.refreshCache(); } if (this.cachedCommands.length === 0) { return undefined; } // The text the user has typed after the `/` — used for pre-filtering. const slashIndex = linePrefix.lastIndexOf("/"); const typedAfterSlash = linePrefix.slice(slashIndex + 1); // Range to replace: from the `/` to the current cursor position. const replaceRange = new vscode.Range( new vscode.Position(position.line, slashIndex), position, ); return this.cachedCommands .filter( (cmd) => typedAfterSlash.length === 0 || cmd.name.toLowerCase().startsWith(typedAfterSlash.toLowerCase()), ) .map((cmd) => this.toCompletionItem(cmd, replaceRange)); } dispose(): void { for (const d of this.disposables) { d.dispose(); } } private async refreshCache(): Promise { try { const all = await this.client.getCommands(); // Only show /sf commands — filter out unrelated extension/skill commands this.cachedCommands = all.filter((cmd) => cmd.name.startsWith("sf")); } catch { // Silently ignore — agent may not be ready yet. } } private toCompletionItem( cmd: SlashCommand, replaceRange: vscode.Range, ): vscode.CompletionItem { const item = new vscode.CompletionItem( `/${cmd.name}`, vscode.CompletionItemKind.Event, ); item.insertText = `/${cmd.name}`; item.filterText = `/${cmd.name}`; item.sortText = cmd.name; item.range = replaceRange; item.commitCharacters = [" ", "\n"]; const sourceNote = `Source: \`${cmd.source}\`${cmd.location ? ` (${cmd.location})` : ""}`; if (cmd.description) { item.detail = cmd.description; item.documentation = new vscode.MarkdownString( `**/${cmd.name}** — ${cmd.description}\n\n${sourceNote}`, ); } else { item.documentation = new vscode.MarkdownString( `**/${cmd.name}**\n\n${sourceNote}`, ); } return item; } }