Address marketplace review feedback

This commit is contained in:
acxtrilla 2026-04-29 15:47:36 +01:00
parent 4a7ecd1e81
commit 6014d208fa
6 changed files with 4567 additions and 125 deletions

View file

@ -1,6 +1,6 @@
# AI Sidebar for Obsidian
AI Sidebar adds a Cursor/VS Code-style assistant panel to Obsidian. It opens from the ribbon, the command palette, or an optional hotkey you configure in settings.
AI Sidebar adds a Cursor/VS Code-style assistant panel to Obsidian. It opens from the ribbon or command palette. You can assign a shortcut from Obsidian's hotkeys settings.
The plugin is desktop-only because v1 can talk to local CLI agents such as Codex, Claude Code, and opencode. It also supports OpenAI-compatible API providers and OAuth-capable providers when they publish desktop OAuth endpoints.
@ -47,7 +47,7 @@ Copy or symlink this folder into a test vault at:
<vault>/.obsidian/plugins/ai-sidebar
```
Then enable the plugin in Obsidian community plugin settings.
If your vault uses a custom configuration folder, replace `.obsidian` with that folder name. Then enable the plugin in Obsidian community plugin settings.
## Provider setup
@ -99,7 +99,7 @@ You can turn memory off, change how many messages are remembered, or clear memor
## Local CLI protocol
Local CLI commands receive a JSON request on stdin:
Local CLI commands receive a prompt argument that includes the user request, instructions, and JSON vault context:
```json
{

View file

@ -1,6 +1,6 @@
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
import { builtinModules } from "module";
const prod = process.argv[2] === "production";
@ -24,7 +24,7 @@ const context = await esbuild.context({
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
...builtinModules,
],
format: "cjs",
target: "es2022",

17
eslint.config.mjs Normal file
View file

@ -0,0 +1,17 @@
import obsidian from "eslint-plugin-obsidianmd";
export default [
...obsidian.configs.recommendedWithLocalesEn,
{
files: ["**/*.ts"],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
ignores: ["main.js", "node_modules/**"],
},
];

155
main.ts
View file

@ -2,11 +2,9 @@ import {
App,
ButtonComponent,
DropdownComponent,
type Hotkey,
ItemView,
MarkdownView,
Modal,
type Modifier,
Notice,
Plugin,
PluginSettingTab,
@ -52,7 +50,6 @@ interface AISidebarSettings {
providers: AgentProvider[];
defaultProviderId: string;
defaultAccessMode: AccessMode;
sidebarHotkey: string;
includeFolders: string;
excludeFolders: string;
maxContextChars: number;
@ -287,9 +284,8 @@ const DEFAULT_SETTINGS: AISidebarSettings = {
],
defaultProviderId: "codex",
defaultAccessMode: "confirm",
sidebarHotkey: "",
includeFolders: "",
excludeFolders: ".obsidian, node_modules, .git",
excludeFolders: "node_modules, .git",
maxContextChars: 45000,
enableConversationMemory: true,
maxMemoryMessages: 30,
@ -307,17 +303,13 @@ export default class AISidebarPlugin extends Plugin {
(leaf) => new AISidebarView(leaf, this),
);
this.addRibbonIcon("sparkles", "Toggle AI Sidebar", () => {
this.addRibbonIcon("sparkles", "Toggle AI sidebar", () => {
void this.toggleSidebar();
});
const toggleCommandHotkeys = this.settings.sidebarHotkey
? [parseHotkey(this.settings.sidebarHotkey)]
: [];
this.addCommand({
id: "toggle-ai-sidebar",
name: "Toggle AI Sidebar",
hotkeys: toggleCommandHotkeys,
id: "toggle-sidebar",
name: "Toggle sidebar",
callback: () => {
void this.toggleSidebar();
},
@ -326,10 +318,6 @@ export default class AISidebarPlugin extends Plugin {
this.addSettingTab(new AISidebarSettingTab(this.app, this));
}
onunload() {
this.app.workspace.detachLeavesOfType(VIEW_TYPE_AI_SIDEBAR);
}
async toggleSidebar() {
const existingLeaf = this.app.workspace.getLeavesOfType(VIEW_TYPE_AI_SIDEBAR).first();
if (existingLeaf) {
@ -344,12 +332,12 @@ export default class AISidebarPlugin extends Plugin {
}
await leaf.setViewState({ type: VIEW_TYPE_AI_SIDEBAR, active: true });
this.app.workspace.revealLeaf(leaf);
await this.app.workspace.revealLeaf(leaf);
}
async loadSettings() {
const loaded = await this.loadData();
this.settings = Object.assign({}, DEFAULT_SETTINGS, loaded);
const loaded = await this.loadData() as Partial<AISidebarSettings> | null;
this.settings = { ...DEFAULT_SETTINGS, ...(loaded ?? {}) };
this.settings.providers = normalizeProviders(this.settings.providers);
this.settings.rememberedMessages = Array.isArray(this.settings.rememberedMessages)
? this.settings.rememberedMessages
@ -423,7 +411,7 @@ class AISidebarView extends ItemView {
}
getDisplayText(): string {
return "AI Sidebar";
return "AI sidebar";
}
getIcon(): string {
@ -452,8 +440,9 @@ class AISidebarView extends ItemView {
await this.refreshContextPreview();
}
async onClose() {
onClose(): Promise<void> {
this.contentEl.empty();
return Promise.resolve();
}
private render() {
@ -461,7 +450,7 @@ class AISidebarView extends ItemView {
this.contentEl.addClass("ai-sidebar");
const header = this.contentEl.createDiv("ai-sidebar__header");
header.createDiv({ cls: "ai-sidebar__title", text: "AI Sidebar" });
header.createDiv({ cls: "ai-sidebar__title", text: "AI sidebar" });
header.createDiv({ cls: "ai-sidebar__subtitle", text: "Vault-aware agents" });
const controls = this.contentEl.createDiv("ai-sidebar__controls");
@ -626,12 +615,12 @@ class AISidebarView extends ItemView {
const provider = this.plugin.getProvider(this.providerId);
if (!provider) {
new Notice("Choose a provider in AI Sidebar settings.");
new Notice("Choose a provider in AI sidebar settings.");
return;
}
if (!isProviderReady(provider)) {
new Notice("Connect or configure this provider in AI Sidebar settings first.");
new Notice("Connect or configure this provider in AI sidebar settings first.");
return;
}
@ -692,7 +681,7 @@ class AISidebarView extends ItemView {
this.messages = [];
await this.plugin.clearMemory();
this.renderMessages();
new Notice("AI Sidebar memory cleared.");
new Notice("AI sidebar memory cleared.");
}
private async handleActions(response: string): Promise<string> {
@ -808,7 +797,7 @@ class AISidebarView extends ItemView {
};
let accessMode: AccessMode | undefined;
const cleanPrompt = prompt.replace(/(?:^|\s)\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match, key, value) => {
const cleanPrompt = prompt.replace(/(?:^|\s)\/(model|reasoning|access|memory):([A-Za-z0-9_.-]+)/gi, (match: string, key: string, value: string) => {
const normalizedKey = String(key).toLowerCase();
const normalizedValue = String(value).toLowerCase();
if (normalizedKey === "model") {
@ -880,7 +869,6 @@ class AISidebarSettingTab extends PluginSettingTab {
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "AI Sidebar" });
new Setting(containerEl)
.setName("Default provider")
@ -910,24 +898,15 @@ class AISidebarSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName("Sidebar hotkey")
.setDesc("Optional shortcut for toggling the sidebar. Examples: Mod+Shift+A, Cmd+Option+Space, Ctrl+Alt+A. Reload the plugin after changing.")
.addText((text) => {
text
.setPlaceholder("Mod+Shift+A")
.setValue(this.plugin.settings.sidebarHotkey)
.onChange(async (value) => {
this.plugin.settings.sidebarHotkey = normalizeHotkey(value);
await this.plugin.saveSettings();
});
});
.setName("Sidebar shortcut")
.setDesc("Set a shortcut from Obsidian's hotkeys settings by searching for toggle sidebar.");
new Setting(containerEl)
.setName("Include folders")
.setDesc("Comma-separated folder prefixes to include. Leave blank for the whole vault.")
.addText((text) => {
text
.setPlaceholder("Projects, Daily Notes")
.setPlaceholder("Projects, daily notes")
.setValue(this.plugin.settings.includeFolders)
.onChange(async (value) => {
this.plugin.settings.includeFolders = value;
@ -991,21 +970,25 @@ class AISidebarSettingTab extends PluginSettingTab {
.addButton((button) => {
button.setButtonText("Clear memory").onClick(async () => {
await this.plugin.clearMemory();
new Notice("AI Sidebar memory cleared.");
new Notice("AI sidebar memory cleared.");
});
});
containerEl.createEl("h3", { text: "Connect a Provider" });
new Setting(containerEl)
.setName("Connect a provider")
.setHeading();
this.renderProviderPresets(containerEl);
containerEl.createEl("h3", { text: "Connected Providers" });
new Setting(containerEl)
.setName("Connected providers")
.setHeading();
for (const provider of this.plugin.settings.providers) {
this.renderProviderSetting(containerEl, provider);
}
new Setting(containerEl)
.setName("Advanced provider")
.setDesc("Create a custom API, OAuth, or local CLI provider.")
.setDesc("Create a custom API, OAUTH, or local CLI provider.")
.addButton((button) => {
button.setButtonText("Add custom").onClick(async () => {
const id = `provider-${Date.now()}`;
@ -1204,7 +1187,9 @@ class ConfirmActionsModal extends Modal {
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl("h2", { text: "Apply AI vault actions?" });
new Setting(contentEl)
.setName("Apply AI vault actions?")
.setHeading();
contentEl.createEl("p", { text: "Review these proposed file changes before they are applied." });
for (const action of this.actions) {
@ -1275,7 +1260,7 @@ async function collectVaultContext(
const baseFiles = app.vault
.getFiles()
.filter((file) => file.extension === "base" && shouldIncludeFile(file, settings))
.filter((file) => file.extension === "base" && shouldIncludeFile(app, file, settings))
.slice(0, 5);
for (const file of baseFiles) {
const item = await readContextFile(app, file, "base file", budget);
@ -1350,7 +1335,7 @@ async function findRelevantFiles(
const scored: Array<{ file: TFile; score: number }> = [];
const files = app.vault
.getFiles()
.filter((file) => file !== activeFile && shouldIncludeFile(file, settings) && isReadableContextFile(file));
.filter((file) => file !== activeFile && shouldIncludeFile(app, file, settings) && isReadableContextFile(file));
for (const file of files) {
const content = (await app.vault.cachedRead(file)).toLowerCase();
@ -1370,9 +1355,9 @@ async function findRelevantFiles(
.map((item) => item.file);
}
function shouldIncludeFile(file: TFile, settings: AISidebarSettings): boolean {
function shouldIncludeFile(app: App, file: TFile, settings: AISidebarSettings): boolean {
const includes = parseFolderList(settings.includeFolders);
const excludes = parseFolderList(settings.excludeFolders);
const excludes = [app.vault.configDir, ...parseFolderList(settings.excludeFolders)];
if (includes.length > 0 && !includes.some((folder) => file.path.startsWith(folder))) return false;
if (excludes.some((folder) => file.path.startsWith(folder))) return false;
return isReadableContextFile(file);
@ -1434,54 +1419,6 @@ function parseProviderModels(provider: AgentProvider): string[] {
return Array.from(new Set(models)).slice(0, 12);
}
function parseHotkey(value: string): Hotkey {
const normalized = normalizeHotkey(value);
const parts = normalized.split("+").filter(Boolean);
const key = parts.pop() || "A";
return {
modifiers: parts as Modifier[],
key,
};
}
function normalizeHotkey(value: string): string {
const aliases: Record<string, string> = {
command: "Mod",
cmd: "Mod",
meta: "Mod",
mod: "Mod",
control: "Ctrl",
ctrl: "Ctrl",
option: "Alt",
opt: "Alt",
alt: "Alt",
shift: "Shift",
};
const parts = value
.split("+")
.map((part) => part.trim())
.filter(Boolean);
if (parts.length === 0) return "";
const key = parts.pop() ?? "A";
const modifiers = parts
.map((part) => aliases[part.toLowerCase()] ?? part)
.filter((part, index, all) => isModifier(part) && all.indexOf(part) === index);
return [...modifiers, normalizeHotkeyKey(key)].join("+");
}
function isModifier(value: string): value is Modifier {
return value === "Mod" || value === "Ctrl" || value === "Alt" || value === "Shift";
}
function normalizeHotkeyKey(key: string): string {
const lower = key.toLowerCase();
if (lower === "space" || key === " ") return "Space";
if (lower === "enter" || lower === "return") return "Enter";
if (lower === "escape" || lower === "esc") return "Escape";
return key.length === 1 ? key.toUpperCase() : key;
}
async function listAgentSkills(app: App, query = ""): Promise<SkillReference[]> {
const normalizedQuery = query.toLowerCase();
const vaultSkills = app.vault
@ -1750,8 +1687,9 @@ async function runOAuthProvider(provider: AgentProvider, request: AgentRequest):
throw new Error(response.text || `HTTP ${response.status}`);
}
if (typeof response.json?.text === "string") return response.json.text;
if (typeof response.json?.output_text === "string") return response.json.output_text;
const responseJson = response.json as { text?: unknown; output_text?: unknown } | undefined;
if (typeof responseJson?.text === "string") return responseJson.text;
if (typeof responseJson?.output_text === "string") return responseJson.output_text;
return response.text;
}
@ -1873,7 +1811,7 @@ function extractOpenAIText(json: unknown): string {
return parts.join("\n");
}
async function startOAuthLogin(provider: AgentProvider): Promise<boolean> {
function startOAuthLogin(provider: AgentProvider): boolean {
if (!provider.authorizationUrl || !provider.clientId) {
new Notice("Add an authorization URL and client ID first.");
return false;
@ -1887,7 +1825,7 @@ async function startOAuthLogin(provider: AgentProvider): Promise<boolean> {
url.searchParams.set("state", crypto.randomUUID());
window.open(url.toString());
new Notice("OAuth sign-in opened in your browser. Paste the returned token into this provider when available.");
new Notice("OAUTH sign-in opened in your browser. Paste the returned token into this provider when available.");
return true;
}
@ -1956,15 +1894,14 @@ function isProviderReady(provider: AgentProvider): boolean {
function normalizeProviders(providers: AgentProvider[]): AgentProvider[] {
return providers.map((provider) => {
const migrated = provider as AgentProvider;
const command = migrated.id === "codex" && migrated.command === "codex"
const command = provider.id === "codex" && provider.command === "codex"
? "codex exec"
: migrated.command ?? "";
: provider.command ?? "";
return {
...migrated,
authType: migrated.authType ?? (migrated.command ? "cli" : "api-key"),
...provider,
authType: provider.authType ?? (provider.command ? "cli" : "api-key"),
command,
connectedAt: migrated.connectedAt,
connectedAt: provider.connectedAt,
};
});
}
@ -2039,7 +1976,7 @@ async function applyVaultAction(app: App, action: VaultAction): Promise<void> {
const current = await app.vault.read(file);
await app.vault.modify(file, `${current}${current.endsWith("\n") ? "" : "\n"}${action.content}`);
} else if (action.type === "delete") {
await app.vault.delete(file);
await app.fileManager.trashFile(file);
} else if (action.type === "rename") {
await ensureParentFolder(app, action.newPath);
await app.fileManager.renameFile(file, normalizeVaultPath(action.newPath));
@ -2071,10 +2008,6 @@ function uniqueFiles(files: TFile[]): TFile[] {
});
}
function sanitizeProviderId(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "provider";
}
function isReasoningEffort(value: string): value is ReasoningEffort {
return value === "low" || value === "medium" || value === "high";
}

4502
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,8 @@
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
"lint": "eslint main.ts package.json"
},
"keywords": [
"obsidian",
@ -19,8 +20,11 @@
"license": "MIT",
"devDependencies": {
"@types/node": "^22.15.3",
"builtin-modules": "^4.0.0",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"esbuild": "^0.25.3",
"eslint": "^9.39.4",
"eslint-plugin-obsidianmd": "^0.2.8",
"obsidian": "^1.8.7",
"typescript": "^5.8.3"
}