Add global skill discovery for AI sidebar

This commit is contained in:
acxtrilla 2026-04-29 12:54:01 +01:00
parent 4fca5b21a3
commit 1ef19eb998
3 changed files with 255 additions and 24 deletions

16
main.js

File diff suppressed because one or more lines are too long

190
main.ts
View file

@ -14,6 +14,9 @@ import {
WorkspaceLeaf,
} from "obsidian";
import { spawn } from "child_process";
import { promises as fs } from "fs";
import { homedir } from "os";
import * as path from "path";
const VIEW_TYPE_AI_SIDEBAR = "ai-sidebar-view";
@ -67,6 +70,12 @@ interface AgentSkill {
content: string;
}
interface SkillReference {
name: string;
path: string;
source: "vault" | "global";
}
interface VaultContext {
activeFile?: VaultContextFile;
selection?: string;
@ -386,6 +395,7 @@ class AISidebarView extends ItemView {
private textareaEl: HTMLTextAreaElement;
private contextEl: HTMLElement;
private slashEl: HTMLElement;
private modelBadgeEl: HTMLElement;
private activeContextPath = "";
private refreshTimer: number | undefined;
private sendButton: ButtonComponent;
@ -453,6 +463,7 @@ class AISidebarView extends ItemView {
.setValue(this.providerId)
.onChange((value) => {
this.providerId = value;
this.updateModelBadge();
});
new DropdownComponent(controls)
@ -494,6 +505,7 @@ class AISidebarView extends ItemView {
});
this.textareaEl.addEventListener("input", () => {
void this.updateSlashCommands();
this.updateModelBadge();
});
this.slashEl = composer.createDiv("ai-sidebar__slash is-hidden");
@ -510,6 +522,9 @@ class AISidebarView extends ItemView {
void this.clearConversation();
});
this.modelBadgeEl = actions.createDiv("ai-sidebar__model-badge");
this.updateModelBadge();
this.sendButton = new ButtonComponent(actions)
.setCta()
.setButtonText("Send")
@ -541,6 +556,17 @@ class AISidebarView extends ItemView {
messageEl.createEl("pre", { text: message.content });
}
if (this.isRunning) {
const thinkingEl = this.threadEl.createDiv("ai-sidebar__message ai-sidebar__message--thinking");
thinkingEl.createDiv({ cls: "ai-sidebar__message-role", text: "assistant" });
const row = thinkingEl.createDiv("ai-sidebar__typing");
row.createSpan({ text: "Thinking" });
const dots = row.createSpan("ai-sidebar__typing-dots");
dots.createSpan();
dots.createSpan();
dots.createSpan();
}
this.threadEl.scrollTo({ top: this.threadEl.scrollHeight });
}
@ -602,10 +628,19 @@ class AISidebarView extends ItemView {
return;
}
const parsed = this.parsePromptControls(rawPrompt, provider);
if (this.isSlashOnlyPrompt(rawPrompt, parsed)) {
await this.applySlashControls(provider, parsed);
this.textareaEl.value = "";
this.updateModelBadge();
await this.refreshContextPreview();
return;
}
this.isRunning = true;
this.sendButton.setButtonText("Running...");
this.sendButton.setDisabled(true);
this.textareaEl.value = "";
const parsed = this.parsePromptControls(rawPrompt, provider);
const effectiveAccessMode = parsed.accessMode ?? this.accessMode;
this.messages.push({ role: "user", content: rawPrompt });
this.renderMessages();
@ -640,6 +675,8 @@ class AISidebarView extends ItemView {
} finally {
this.isRunning = false;
this.sendButton.setButtonText("Send");
this.sendButton.setDisabled(false);
this.updateModelBadge();
this.renderMessages();
}
}
@ -695,7 +732,7 @@ class AISidebarView extends ItemView {
}
this.slashEl.removeClass("is-hidden");
for (const command of commands.slice(0, 10)) {
for (const command of commands.slice(0, 6)) {
const item = this.slashEl.createDiv("ai-sidebar__slash-item");
item.createDiv({ cls: "ai-sidebar__slash-name", text: command.label });
item.createDiv({ cls: "ai-sidebar__slash-path", text: command.description });
@ -704,7 +741,7 @@ class AISidebarView extends ItemView {
this.insertSlashToken(command.insert);
});
}
for (const skill of skills.slice(0, 8)) {
for (const skill of skills.slice(0, 10)) {
const item = this.slashEl.createDiv("ai-sidebar__slash-item");
item.createDiv({ cls: "ai-sidebar__slash-name", text: `/${skill.name}` });
item.createDiv({ cls: "ai-sidebar__slash-path", text: skill.path });
@ -723,7 +760,7 @@ class AISidebarView extends ItemView {
private currentSlashQuery(): string | null {
const cursor = this.textareaEl.selectionStart;
const beforeCursor = this.textareaEl.value.slice(0, cursor);
const match = beforeCursor.match(/(?:^|\s)\/([A-Za-z0-9_.-]*)$/);
const match = beforeCursor.match(/(?:^|\s)\/([A-Za-z0-9_.:-]*)$/);
return match ? match[1].toLowerCase() : null;
}
@ -732,7 +769,7 @@ class AISidebarView extends ItemView {
const value = this.textareaEl.value;
const beforeCursor = value.slice(0, cursor);
const afterCursor = value.slice(cursor);
const replaced = beforeCursor.replace(/(?:^|\s)\/([A-Za-z0-9_.-]*)$/, (match) => {
const replaced = beforeCursor.replace(/(?:^|\s)\/([A-Za-z0-9_.:-]*)$/, (match) => {
const prefix = match.startsWith(" ") ? " " : "";
return `${prefix}${token} `;
});
@ -774,12 +811,49 @@ class AISidebarView extends ItemView {
}).trim();
return {
cleanPrompt: cleanPrompt || prompt,
cleanPrompt,
selectedSkillNames: this.extractSelectedSkillNames(prompt),
options,
accessMode,
};
}
private isSlashOnlyPrompt(prompt: string, parsed: ParsedPrompt): boolean {
return parsed.cleanPrompt.length === 0 && this.extractSelectedSkillNames(prompt).length === 0;
}
private async applySlashControls(provider: AgentProvider, parsed: ParsedPrompt) {
const changes: string[] = [];
if (parsed.options.model && parsed.options.model !== provider.model) {
provider.model = parsed.options.model;
changes.push(`model ${parsed.options.model}`);
}
if (parsed.options.reasoningEffort && parsed.options.reasoningEffort !== provider.reasoningEffort) {
provider.reasoningEffort = parsed.options.reasoningEffort;
changes.push(`reasoning ${parsed.options.reasoningEffort}`);
}
if (parsed.accessMode && parsed.accessMode !== this.accessMode) {
this.accessMode = parsed.accessMode;
this.plugin.settings.defaultAccessMode = parsed.accessMode;
changes.push(`access ${this.accessModeLabel()}`);
}
if (parsed.options.memoryEnabled !== this.plugin.settings.enableConversationMemory) {
this.plugin.settings.enableConversationMemory = parsed.options.memoryEnabled;
changes.push(`memory ${parsed.options.memoryEnabled ? "on" : "off"}`);
}
await this.plugin.saveSettings();
new Notice(changes.length > 0 ? `Updated ${changes.join(", ")}.` : "Slash command applied.");
}
private updateModelBadge() {
if (!this.modelBadgeEl) return;
const provider = this.plugin.getProvider(this.providerId);
const override = this.textareaEl?.value.match(/(?:^|\s)\/model:([A-Za-z0-9_.:/-]+)/i)?.[1];
const model = override || provider?.model || parseProviderModels(provider ?? DEFAULT_SETTINGS.providers[0]).first() || "default";
this.modelBadgeEl.setText(model);
this.modelBadgeEl.toggleClass("is-override", Boolean(override));
this.modelBadgeEl.setAttr("aria-label", override ? `Model override: ${model}` : `Model: ${model}`);
}
}
class AISidebarSettingTab extends PluginSettingTab {
@ -1334,27 +1408,90 @@ function parseProviderModels(provider: AgentProvider): string[] {
return Array.from(new Set(models)).slice(0, 12);
}
async function listAgentSkills(app: App, query = ""): Promise<Array<{ name: string; path: string }>> {
async function listAgentSkills(app: App, query = ""): Promise<SkillReference[]> {
const normalizedQuery = query.toLowerCase();
const skills = app.vault
const vaultSkills = app.vault
.getFiles()
.filter((file) => isSkillFile(file))
.map((file) => ({
name: skillNameFromPath(file.path),
path: file.path,
}))
source: "vault" as const,
}));
const adapterSkills = await listAgentSkillsFromAdapter(app);
const globalSkills = await listGlobalAgentSkills();
const skills = [...vaultSkills, ...adapterSkills, ...globalSkills]
.filter((skill) => !normalizedQuery || skill.name.toLowerCase().includes(normalizedQuery))
.sort((a, b) => a.name.localeCompare(b.name));
const seen = new Set<string>();
return skills.filter((skill) => {
const key = skill.name.toLowerCase();
const key = `${skill.source}:${skill.name.toLowerCase()}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
async function listAgentSkillsFromAdapter(app: App): Promise<SkillReference[]> {
const roots = [".agent/skills", ".agents/skills", "agent/skills", "agents/skills"];
const files: string[] = [];
for (const root of roots) {
files.push(...await listAdapterFiles(app, root));
}
return files
.filter((path) => isSkillPath(path))
.map((path) => ({
name: skillNameFromPath(path),
path,
source: "vault" as const,
}));
}
async function listGlobalAgentSkills(): Promise<SkillReference[]> {
const roots = [
path.join(homedir(), ".agent", "skills"),
path.join(homedir(), ".agents", "skills"),
];
const files: string[] = [];
for (const root of roots) {
files.push(...await listFilesystemFiles(root));
}
return files
.filter((filePath) => isSkillPath(filePath))
.map((filePath) => ({
name: skillNameFromPath(filePath),
path: filePath,
source: "global" as const,
}));
}
async function listFilesystemFiles(folder: string): Promise<string[]> {
try {
const entries = await fs.readdir(folder, { withFileTypes: true });
const results = await Promise.all(entries.map(async (entry) => {
const childPath = path.join(folder, entry.name);
if (entry.isDirectory()) return listFilesystemFiles(childPath);
if (entry.isFile()) return [childPath];
return [];
}));
return results.flat();
} catch {
return [];
}
}
async function listAdapterFiles(app: App, folder: string): Promise<string[]> {
try {
const listed = await app.vault.adapter.list(folder);
const childFiles = listed.files;
const nested = await Promise.all(listed.folders.map((child) => listAdapterFiles(app, child)));
return [...childFiles, ...nested.flat()];
} catch {
return [];
}
}
async function readSelectedSkills(
app: App,
selectedSkillNames: string[],
@ -1364,22 +1501,43 @@ async function readSelectedSkills(
if (names.size === 0) return [];
const skills: AgentSkill[] = [];
for (const file of app.vault.getFiles().filter((candidate) => isSkillFile(candidate))) {
const name = skillNameFromPath(file.path);
const skillFiles = [...await listAgentSkillsFromAdapter(app), ...await listGlobalAgentSkills()];
const vaultFiles = app.vault
.getFiles()
.filter((candidate) => isSkillFile(candidate))
.map((file) => ({ name: skillNameFromPath(file.path), path: file.path, source: "vault" as const }));
for (const skillFile of [...vaultFiles, ...skillFiles]) {
const name = skillFile.name;
if (!names.has(name.toLowerCase()) || !budget.hasRoom()) continue;
const content = await app.vault.cachedRead(file);
const content = await readSkillContent(app, skillFile);
skills.push({
name,
path: file.path,
path: skillFile.path,
content: budget.take(content),
});
}
return skills;
}
async function readSkillContent(app: App, skill: SkillReference): Promise<string> {
if (skill.source === "global") return fs.readFile(skill.path, "utf8");
const file = app.vault.getAbstractFileByPath(skill.path);
if (file instanceof TFile) return app.vault.cachedRead(file);
return app.vault.adapter.read(skill.path);
}
function isSkillFile(file: TFile): boolean {
if (!["md", "txt", "json", "yaml", "yml"].includes(file.extension)) return false;
return file.path.startsWith(".agent/skills/") || file.path.startsWith(".agents/skills/");
return isSkillPath(file.path);
}
function isSkillPath(path: string): boolean {
const extension = path.split(".").last()?.toLowerCase() ?? "";
if (!["md", "txt", "json", "yaml", "yml", "prompt"].includes(extension)) return false;
const normalized = path.replace(/^\/+/, "");
return normalized.startsWith(".agent/skills/")
|| normalized.startsWith(".agents/skills/")
|| normalized.startsWith("agent/skills/")
|| normalized.startsWith("agents/skills/");
}
function skillNameFromPath(path: string): string {

View file

@ -29,6 +29,11 @@
gap: 8px;
}
.ai-sidebar__composer-actions {
align-items: center;
flex-wrap: wrap;
}
.ai-sidebar__controls select {
min-width: 0;
width: 100%;
@ -93,6 +98,11 @@
color: var(--text-on-accent);
}
.ai-sidebar__message--thinking {
background: var(--background-secondary);
opacity: 0.95;
}
.ai-sidebar__message-role {
color: var(--text-muted);
font-size: 11px;
@ -108,6 +118,49 @@
white-space: pre-wrap;
}
.ai-sidebar__typing {
align-items: center;
color: var(--text-muted);
display: flex;
font-size: 13px;
gap: 8px;
}
.ai-sidebar__typing-dots {
display: inline-flex;
gap: 4px;
}
.ai-sidebar__typing-dots span {
animation: ai-sidebar-dot-pulse 1.2s infinite ease-in-out;
background: var(--text-muted);
border-radius: 999px;
height: 5px;
opacity: 0.35;
width: 5px;
}
.ai-sidebar__typing-dots span:nth-child(2) {
animation-delay: 0.16s;
}
.ai-sidebar__typing-dots span:nth-child(3) {
animation-delay: 0.32s;
}
@keyframes ai-sidebar-dot-pulse {
0%,
80%,
100% {
opacity: 0.25;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-3px);
}
}
.ai-sidebar__composer {
border-top: 1px solid var(--background-modifier-border);
display: flex;
@ -123,6 +176,26 @@
width: 100%;
}
.ai-sidebar__model-badge {
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
border-radius: 999px;
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
line-height: 1;
max-width: 180px;
overflow: hidden;
padding: 7px 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-sidebar__model-badge.is-override {
border-color: var(--interactive-accent);
color: var(--text-normal);
}
.ai-sidebar__slash {
background: var(--background-primary);
border: 1px solid var(--background-modifier-border);