117 lines
3.3 KiB
TypeScript
117 lines
3.3 KiB
TypeScript
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<vscode.CompletionItem[] | undefined> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|