diff --git a/changelogs/v2.5.18.md b/changelogs/v2.5.18.md new file mode 100644 index 000000000..79d1a425a --- /dev/null +++ b/changelogs/v2.5.18.md @@ -0,0 +1,20 @@ +## Fixes + +- **The AI-change banner on notes is now opaque.** The "AI is editing this note…" / "N lines changed since you last viewed this note" banner pinned to the top of the note editor had a background of `color-mix(… var(--accent) 12%, transparent)` — ~88% see-through — so note content scrolled visibly behind it. The tint now sits on an opaque `var(--background)` base instead of `transparent`, masking the content underneath while keeping the exact same accent colour. + +- **Chat threads now survive app restarts and updates.** Conversations were always written to SQLite, but on the desktop app they were only ever *read back* from Chromium localStorage — which an app update can clear or relocate, orphaning every thread and message in the database with no way to see them. Cairn now re-hydrates chat threads and messages from SQLite on launch (and when switching workspaces), so your conversations persist even if localStorage is wiped. The last-active thread is also remembered and re-selected on restart; if that pointer is lost, the app reuses your existing scoped thread instead of silently starting a blank one. + +- **Clarifying questions no longer leak into the wrong chat.** When the AI asked you questions (the inline question form from `ask_questions`), switching to a different chat thread rendered that same form there too. The pending-question state lived in an unscoped hook `useState` and the `ChatPanel` never remounts on thread switch, so the form followed you around. Pending questions are now tagged with the thread they arrived on and only render while that thread is active — switch away and the form disappears; switch back and it returns. + +- **Deleted notes and projects now go to the OS trash instead of being permanently erased.** When you delete a note or a project from the app (including via the in-app AI chat), Cairn now moves its `.md` file/folder to the system Trash (macOS) or Recycle Bin (Windows) via `shell.trashItem`, so you can restore it from Finder/Explorer if a delete was a mistake. If the OS trash is unavailable, it falls back to a permanent delete. Note: the standalone `cairn-mcp` binary (used by agents while Cairn is closed) still hard-deletes, since a headless process has no desktop trash. +- **Deleting a project no longer wipes another same-named project's notes.** Project note folders on disk are keyed by the project NAME slug (not the id), and project names aren't unique — so if two projects shared a name (e.g. a duplicate "Personal" created by a timed-out, retried request), deleting one ran `fs.rmSync` on the shared folder and destroyed the survivor's `.md` files while its database rows lived on. Project deletion (both the app's IPC handler and the MCP `delete_project` tool) now deletes the database rows first, then only removes the on-disk folder when no surviving project still occupies that slug. The same survivor guard was added to the post-merge source-folder cleanup, and the MCP delete path was reordered to delete the DB rows before touching disk (previously it removed the folder first). + +- **Slash commands now send on Enter after tab-completing.** When you tab- or click-completed a slash command whose completion is itself a command (e.g. `/compact`), the input re-detected the leading `/` and immediately re-opened the command palette — so the next Enter re-opened the palette instead of sending. Completing a command now suppresses the trigger re-scan briefly and moves the cursor to the end, so the command is ready to send on the next Enter press. + +- **Removed the redundant "Project context" panel from Settings → AI & Chat.** The MCP settings block contained a read-only "Project context" snippet generator (per-project system-prompt / Claude / Cursor config strings for wiring up external MCP clients) that duplicated the main MCP config blocks and confused its purpose. It wrote nothing and had no runtime effect, so it has been removed to simplify the page. + +## Features + +- **Custom slash commands.** You can now create your own slash commands from **Settings → Commands**. Each command has a name (used as `/name`), a description, the text it inserts into the input, and a scope that controls whether it appears in the AI chat panel, the coding-agent input, or both. Custom commands are workspace-global and persist in the local database. The new Commands settings page also documents every built-in command in one place — and a custom command with the same name as a built-in overrides it, so you can tweak what a built-in inserts. (The reserved, executable built-ins `/compact` and `/archive-chat` can't be overridden.) + +- **Browse community slash commands.** From **Settings → Commands → Browse Community**, you can now install ready-made slash commands from the cairn-community registry with one click. The catalog is searchable and filterable by category, shows an "Update" action when an installed command has changed upstream (with a confirmation before it overwrites a command you've edited locally), and installs commands workspace-globally so they're immediately usable by typing `/` in any chat or agent input. Re-installing a command reuses the existing entry (matched by community id or name) instead of creating a duplicate. (The registry manifest gained a new `command` entry kind and a `commands[]` array in v2; older manifests still parse.) diff --git a/electron/db/queries.ts b/electron/db/queries.ts index 87533c66b..b0e0b024f 100644 --- a/electron/db/queries.ts +++ b/electron/db/queries.ts @@ -44,6 +44,7 @@ import { toColumn, toCard, toTag, + toSlashCommand, toChatThread, toChatMessage, toMcpNotification, @@ -951,6 +952,75 @@ export function deleteTag(db: Database.Database, id: string) { db.prepare("DELETE FROM tags WHERE id = ?").run(id); } +// ── Slash commands ──────────────────────────── + +export function getSlashCommands(db: Database.Database, workspaceId?: string) { + const rows = workspaceId + ? db.prepare("SELECT * FROM slash_commands WHERE workspace_id = ? ORDER BY name").all(workspaceId) + : db.prepare("SELECT * FROM slash_commands ORDER BY name").all(); + return rows.map(toSlashCommand); +} + +export function createSlashCommand(db: Database.Database, c: { + id: string; + workspaceId: string; + name: string; + description?: string; + insertText?: string; + scope?: string; + source?: string; + communityId?: string; +}) { + const now = ts(); + db.prepare(` + INSERT INTO slash_commands + (id, workspace_id, name, description, insert_text, scope, source, community_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + c.id, + c.workspaceId, + c.name, + c.description ?? "", + c.insertText ?? "", + c.scope ?? "both", + c.source ?? "custom", + c.communityId ?? null, + now, + now + ); + return toSlashCommand(db.prepare("SELECT * FROM slash_commands WHERE id = ?").get(c.id)); +} + +export function updateSlashCommand(db: Database.Database, id: string, patch: { + name?: string; + description?: string; + insertText?: string; + scope?: string; +}) { + const now = ts(); + db.prepare(` + UPDATE slash_commands SET + name = COALESCE(?, name), + description = COALESCE(?, description), + insert_text = COALESCE(?, insert_text), + scope = COALESCE(?, scope), + updated_at = ? + WHERE id = ? + `).run( + patch.name ?? null, + patch.description ?? null, + patch.insertText ?? null, + patch.scope ?? null, + now, + id + ); + return toSlashCommand(db.prepare("SELECT * FROM slash_commands WHERE id = ?").get(id)); +} + +export function deleteSlashCommand(db: Database.Database, id: string) { + db.prepare("DELETE FROM slash_commands WHERE id = ?").run(id); +} + // ── Chat ────────────────────────────────────── export function getChatThreads(db: Database.Database, workspaceId: string) { diff --git a/electron/db/schema.ts b/electron/db/schema.ts index 0eb6711e1..5a114c47a 100644 --- a/electron/db/schema.ts +++ b/electron/db/schema.ts @@ -774,6 +774,31 @@ const MIGRATIONS: Migration[] = [ db.exec("ALTER TABLE custom_services ADD COLUMN oauth_config TEXT"); } }, + + // v31: User-defined & community slash commands. Workspace-global custom + // commands that surface in the chat / agent input palettes. Built-in commands + // stay as code constants; only custom + community-installed commands persist + // here. `scope` picks which pane(s) show the command; `source` records + // provenance ('custom' | 'community'); `community_id` links an installed + // command back to its cairn-community manifest entry. Device-local (not synced) + // — commands are a workspace-authoring convenience, not user data. + (db) => { + db.exec(` + CREATE TABLE IF NOT EXISTS slash_commands ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + insert_text TEXT NOT NULL DEFAULT '', + scope TEXT NOT NULL DEFAULT 'both', -- 'chat' | 'agent' | 'both' + source TEXT NOT NULL DEFAULT 'custom', -- 'custom' | 'community' + community_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_slash_commands_workspace ON slash_commands(workspace_id); + `); + }, ]; export function applySchema(db: Database.Database): void { diff --git a/electron/ipc/db-handlers.ts b/electron/ipc/db-handlers.ts index 22c54e9df..1a66948b9 100644 --- a/electron/ipc/db-handlers.ts +++ b/electron/ipc/db-handlers.ts @@ -14,7 +14,7 @@ import path from "path"; import { registerIpcHandle, registerIpcOn } from "./registry"; import { handle, getProjectName, type DbContext } from "./result-helpers"; import * as q from "../db/queries"; -import { writeNoteFile, deleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, reconcileProjectFolders, stripMarkdown, findNoteFilePath } from "../notes-files"; +import { writeNoteFile, deleteNoteFile, hardDeleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, reconcileProjectFolders, stripMarkdown, findNoteFilePath } from "../notes-files"; import { suppressNextChange } from "../file-watcher"; import { executeTool as executeMcpTool } from "../mcp/tools"; import { executeReadTool } from "../lib/read-tools"; @@ -168,7 +168,14 @@ export function registerDbHandlers(ctx: DbContext): void { // Delete from DB first so if it fails, the .md files are still intact for recovery. q.deleteProject(ctx.db, id); if (project) { - deleteProjectNotesDir(ctx.workspacePath, project.name); + // The notes folder is keyed by the project NAME slug, not the id, and + // names are not unique. Only remove the folder if no surviving project + // still shares that slug — otherwise we'd wipe a duplicate's .md files. + // Scope survivors to this project's workspace: folders live under this + // workspace's tree, so a same-named project in ANOTHER workspace is not a + // real survivor and must not block the delete. + const survivorNames = q.getProjects(ctx.db, project.workspaceId).map((p) => p.name); + deleteProjectNotesDir(ctx.workspacePath, project.name, survivorNames); } })); @@ -204,9 +211,13 @@ export function registerDbHandlers(ctx: DbContext): void { } } // Remove the source project's on-disk folder (now that its notes have - // been rewritten under the target). Best-effort. + // been rewritten under the target). Best-effort, and only when no + // surviving project shares the source name's slug — scoped to the + // target's workspace (both folders live under this workspace's tree). try { - deleteProjectNotesDir(ctx.workspacePath, result.sourceName); + const mergeWsId = q.getProjectById(ctx.db, targetId)?.workspaceId; + const survivorNames = q.getProjects(ctx.db, mergeWsId).map((p) => p.name); + deleteProjectNotesDir(ctx.workspacePath, result.sourceName, survivorNames); } catch (e) { console.warn("[merge] failed to remove source project folder:", e instanceof Error ? e.message : e); } @@ -398,8 +409,12 @@ export function registerDbHandlers(ctx: DbContext): void { // New file is in place; deleting the stale old copy is best-effort // (a failure here only leaves a duplicate that the watcher would // re-import into the old project — non-fatal, and the DB move stands). + // Use a synchronous HARD delete (not the OS-trash remover): this is a + // move-internal duplicate, not a user delete, so it must not land in + // the Trash and must be gone before we return, or an async trasher + // would race the file-watcher re-importing it into the old project. try { - deleteNoteFile(ctx.workspacePath, oldProjectName, id); + hardDeleteNoteFile(ctx.workspacePath, oldProjectName, id); } catch (e) { console.warn("[notes] failed to remove old-project file after move:", e instanceof Error ? e.message : e); } @@ -636,4 +651,10 @@ export function registerDbHandlers(ctx: DbContext): void { registerIpcHandle("db:tag:create", (_e, args: Parameters[1]) => handle(() => q.createTag(ctx.db, args))); registerIpcHandle("db:tag:update", (_e, { id, patch }) => handle(() => q.updateTag(ctx.db, id, patch))); registerIpcHandle("db:tag:delete", (_e, { id }) => handle(() => q.deleteTag(ctx.db, id))); + + // ── Slash commands ───────────────────────────────── + registerIpcHandle("db:command:list", (_e, { workspaceId }) => handle(() => q.getSlashCommands(ctx.db, workspaceId))); + registerIpcHandle("db:command:create", (_e, args: Parameters[1]) => handle(() => q.createSlashCommand(ctx.db, args))); + registerIpcHandle("db:command:update", (_e, { id, patch }) => handle(() => q.updateSlashCommand(ctx.db, id, patch))); + registerIpcHandle("db:command:delete", (_e, { id }) => handle(() => q.deleteSlashCommand(ctx.db, id))); } diff --git a/electron/lib/community-registry.test.ts b/electron/lib/community-registry.test.ts index c5d536295..e32f8f1cb 100644 --- a/electron/lib/community-registry.test.ts +++ b/electron/lib/community-registry.test.ts @@ -58,6 +58,22 @@ const VALID = { }, }, ], + commands: [ + { + id: "command-standup", + author: "cairn", + version: "1.0.0", + category: "Automation", + tags: ["standup"], + blurb: "Standup update.", + definition: { + name: "standup", + description: "Standup", + insertText: "Summarise recent activity.", + scope: "chat", + }, + }, + ], }; function fetchResponse(opts: { @@ -91,6 +107,37 @@ describe("parseManifest", () => { expect(m.services[0].definition.name).toBe("Weather"); }); + it("parses community slash commands (manifest v2)", () => { + const m = parseManifest(VALID); + expect(m.commands).toHaveLength(1); + expect(m.commands[0].definition.name).toBe("standup"); + expect(m.commands[0].definition.scope).toBe("chat"); + }); + + it("tolerates a manifest with no commands key (pre-v2)", () => { + const { commands, ...noCommands } = VALID; + void commands; + const m = parseManifest(noCommands); + expect(m.commands).toEqual([]); + }); + + it("drops a command with an invalid name or scope", () => { + const bad = { + ...VALID, + commands: [ + { id: "x", author: "a", version: "1.0.0", tags: [], blurb: "b", + definition: { name: "Bad Name", insertText: "hi", scope: "chat" } }, + { id: "y", author: "a", version: "1.0.0", tags: [], blurb: "b", + definition: { name: "ok", insertText: "hi", scope: "nope" } }, + { id: "z", author: "a", version: "1.0.0", tags: [], blurb: "b", + definition: { name: "good", insertText: "hi", scope: "both" } }, + ], + }; + const m = parseManifest(bad); + expect(m.commands).toHaveLength(1); + expect(m.commands[0].definition.name).toBe("good"); + }); + it("drops an entry with a non-https baseUrl (never trusts an insecure URL)", () => { const bad = structuredClone(VALID); bad.mcpServers[0].definition.baseUrl = "http://insecure.example.com/mcp"; diff --git a/electron/lib/community-registry.ts b/electron/lib/community-registry.ts index 9ca505acf..e970b9f79 100644 --- a/electron/lib/community-registry.ts +++ b/electron/lib/community-registry.ts @@ -143,7 +143,7 @@ export async function fetchManifest(opts?: { force?: boolean }): Promise { const outDir = path.join(__dirname, "../out"); setupProtocol(outDir); + // Route deleted note files/folders to the OS trash (Finder/Explorer) so a + // user can restore them, instead of a permanent fs delete. shell.trashItem is + // async + main-process-only; we fire-and-forget (the DB row is already gone) + // and fall back to a hard delete if trashing fails (e.g. no desktop trash). + setPathRemover((targetPath) => { + shell.trashItem(targetPath).catch(() => { + try { fs.rmSync(targetPath, { recursive: true, force: true }); } catch { /* ignore */ } + }); + }); + const userDataPath = app.getPath("userData"); // ── Resolve workspace path ──────────────────────────────────────────── diff --git a/electron/mcp/tools/projects.ts b/electron/mcp/tools/projects.ts index afdc85654..252a02941 100644 --- a/electron/mcp/tools/projects.ts +++ b/electron/mcp/tools/projects.ts @@ -1,13 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import path from "path"; -import fs from "fs"; import Database from "better-sqlite3"; import * as q from "../../db/queries"; import { newId } from "../../db/utils"; import { DEFAULT_COLUMNS } from "../../db/defaults"; import { Snapshot, insertNotification } from "../db"; -import { toSlug } from "../../shared/text-utils"; -import { renameProjectNotesDir } from "../../shared/notes-io"; +import { renameProjectNotesDir, deleteProjectNotesDir } from "../../shared/notes-io"; export function upsert_project(db: Database.Database, snap: Snapshot, workspacePath: string, args: Record) { if (args.projectId) { @@ -62,12 +59,16 @@ export function upsert_project(db: Database.Database, snap: Snapshot, workspaceP export function delete_project(db: Database.Database, snap: Snapshot, workspacePath: string, args: Record) { const project = snap.projects.find((p) => p.id === args.projectId); if (!project) return { error: "Project not found" }; - // Delete notes dir - const notesDir = path.join(workspacePath, toSlug(project.name)); - if (fs.existsSync(notesDir)) { - try { fs.rmSync(notesDir, { recursive: true, force: true }); } catch { /* ignore */ } - } + // Delete the DB rows first, then remove the notes folder — but only if no + // surviving project still shares this name's slug. The folder is keyed by + // the project NAME slug (not the id) and names are not unique, so blindly + // deleting it would destroy a same-named duplicate's .md files (data loss). q.deleteProject(db, args.projectId as string); + // Scope survivors to this project's workspace — a same-named project in + // another workspace lives in a different folder tree and must not block this + // delete. + const survivorNames = q.getProjects(db, project.workspaceId).map((p) => p.name); + deleteProjectNotesDir(workspacePath, project.name, survivorNames); insertNotification(db, "delete_project", "Project deleted", `"${project.name}" was deleted`); return { deleted: true, id: args.projectId, name: project.name }; } diff --git a/electron/notes-files.ts b/electron/notes-files.ts index e5297b2e2..8dd8ea98d 100644 --- a/electron/notes-files.ts +++ b/electron/notes-files.ts @@ -16,10 +16,12 @@ import { NoteFileData as NoteData, writeNoteFile, deleteNoteFile, + hardDeleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, parseNoteFile, pruneEmptyDirsUpTo, + setPathRemover, CAIRN_FRONTMATTER_KEYS } from "./shared/notes-io"; @@ -35,10 +37,12 @@ export { NoteData, writeNoteFile, deleteNoteFile, + hardDeleteNoteFile, deleteProjectNotesDir, renameProjectNotesDir, parseNoteFile, - pruneEmptyDirsUpTo + pruneEmptyDirsUpTo, + setPathRemover }; diff --git a/electron/preload.ts b/electron/preload.ts index 82e626ae0..f716cc5eb 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,9 +53,14 @@ interface RegistryServiceEntry extends RegistryEntryMeta { enabled: boolean; }; } +interface RegistryCommandEntry extends RegistryEntryMeta { + definition: { + name: string; description?: string; insertText: string; scope: "chat" | "agent" | "both"; + }; +} interface CommunityManifest { version: number; updatedAt: string; - mcpServers: RegistryMcpEntry[]; services: RegistryServiceEntry[]; + mcpServers: RegistryMcpEntry[]; services: RegistryServiceEntry[]; commands: RegistryCommandEntry[]; } interface RegistryFetchResult { manifest: CommunityManifest; fromCache: boolean; cachedAt?: string; error?: string; @@ -211,6 +216,14 @@ const api = { delete: (id: string) => invoke("db:tag:delete", { id }), }, + // ── Slash commands ─────────────────────────── + command: { + list: (workspaceId?: string) => invoke("db:command:list", { workspaceId }), + create: (args: unknown) => invoke("db:command:create", args), + update: (id: string, patch: unknown) => invoke("db:command:update", { id, patch }), + delete: (id: string) => invoke("db:command:delete", { id }), + }, + // ── Chat ───────────────────────────────────── chat: { threads: (workspaceId: string) => invoke("db:chat:threads", { workspaceId }), diff --git a/electron/shared/db-mappers.ts b/electron/shared/db-mappers.ts index 4f170c446..af709e55a 100644 --- a/electron/shared/db-mappers.ts +++ b/electron/shared/db-mappers.ts @@ -233,6 +233,21 @@ export function toTag(row: any) { }; } +export function toSlashCommand(row: any) { + return { + id: row.id as string, + workspaceId: row.workspace_id as string, + name: row.name as string, + description: (row.description ?? "") as string, + insertText: (row.insert_text ?? "") as string, + scope: (row.scope ?? "both") as "chat" | "agent" | "both", + source: (row.source ?? "custom") as "custom" | "community", + communityId: (row.community_id ?? undefined) as string | undefined, + createdAt: row.created_at as string, + updatedAt: row.updated_at as string, + }; +} + export function toChatThread(row: any) { return { id: row.id as string, diff --git a/electron/shared/notes-io.test.ts b/electron/shared/notes-io.test.ts index 619ceae67..00da4f698 100644 --- a/electron/shared/notes-io.test.ts +++ b/electron/shared/notes-io.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import fs from "fs"; import path from "path"; import os from "os"; -import { writeNoteFile, type NoteFileData } from "./notes-io"; +import { writeNoteFile, deleteProjectNotesDir, deleteNoteFile, hardDeleteNoteFile, setPathRemover, type NoteFileData, type PathRemover } from "./notes-io"; let tmpDir: string; @@ -171,3 +171,154 @@ describe("writeNoteFile write strategy", () => { } }); }); + +/** + * Regression tests for the project-delete data-loss bug. + * + * Project note folders on disk are keyed by the project NAME slug, not the id, + * and project names are NOT unique. So two projects sharing a name (e.g. a + * duplicate "Personal" created by a timed-out+retried request) share one + * folder. Deleting one used to `fs.rmSync` the shared folder unconditionally, + * destroying the survivor's .md files. deleteProjectNotesDir must skip the + * delete when a surviving project still occupies the same slug. + */ +describe("deleteProjectNotesDir slug-collision guard", () => { + it("removes the folder when no other project shares the slug", () => { + const dir = path.join(tmpDir, "Personal"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "note.md"), "keep me"); + + deleteProjectNotesDir(tmpDir, "Personal", []); + + expect(fs.existsSync(dir)).toBe(false); + }); + + it("does NOT remove the folder when a surviving project shares the slug", () => { + const dir = path.join(tmpDir, "Personal"); + fs.mkdirSync(dir, { recursive: true }); + const fp = path.join(dir, "note.md"); + fs.writeFileSync(fp, "another project's data"); + + // A same-named duplicate still exists → the shared folder must be preserved. + deleteProjectNotesDir(tmpDir, "Personal", ["Personal"]); + + expect(fs.existsSync(fp)).toBe(true); + expect(fs.readFileSync(fp, "utf-8")).toBe("another project's data"); + }); + + it("guards on the slug, not the exact name (spacing variants collide)", () => { + // "My Project" (double space) and "My Project" both slugify to "My Project". + const dir = path.join(tmpDir, "My Project"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "note.md"), "data"); + + deleteProjectNotesDir(tmpDir, "My Project", ["My Project"]); + + expect(fs.existsSync(dir)).toBe(true); + }); + + it("is a no-op when the folder does not exist", () => { + expect(() => deleteProjectNotesDir(tmpDir, "Nonexistent", [])).not.toThrow(); + }); +}); + +/** + * The Electron main process routes deletes to the OS trash via setPathRemover + * (shell.trashItem) so notes/projects are restorable. The MCP process (no + * Electron shell) keeps the default hard fs delete. These tests verify the + * injection point without depending on a real desktop trash. + */ +describe("setPathRemover — pluggable trash vs hard delete", () => { + afterEach(() => setPathRemover()); // always reset to the fs default + + it("routes note-file deletes through the injected remover", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + expect(fs.existsSync(fp)).toBe(true); + + const trashed: string[] = []; + const remover: PathRemover = (target) => { trashed.push(target); }; + setPathRemover(remover); + + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + // The injected remover was called with the note's path; because our fake + // remover doesn't actually delete, the file is still on disk (proving the + // hard fs.unlinkSync no longer runs directly). + expect(trashed).toEqual([fp]); + expect(fs.existsSync(fp)).toBe(true); + }); + + it("routes project-folder deletes through the injected remover", () => { + const dir = path.join(tmpDir, "My Project"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "note.md"), "data"); + + const trashed: string[] = []; + setPathRemover((target) => { trashed.push(target); }); + + deleteProjectNotesDir(tmpDir, "My Project", []); + + expect(trashed).toEqual([dir]); + expect(fs.existsSync(dir)).toBe(true); // fake remover didn't delete + }); + + it("falls back to a hard delete when the remover throws", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + setPathRemover(() => { throw new Error("trash unavailable"); }); + + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + // Fallback fs delete ran → file is gone despite the trasher failing. + expect(fs.existsSync(fp)).toBe(false); + }); + + it("hard-deletes by default (MCP process, no shell injected)", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + // No setPathRemover call → default hardRemove. + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + expect(fs.existsSync(fp)).toBe(false); + }); + + it("falls back to a hard delete when an ASYNC remover rejects", async () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + // Async trasher that rejects (e.g. shell.trashItem unavailable). removePath + // wraps it in a promise whose .catch performs the hard-delete fallback. + const remover: PathRemover = () => Promise.reject(new Error("no trash")); + setPathRemover(remover); + + deleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + // Removal completes asynchronously — flush the microtask queue, then assert + // the fallback fs delete ran. + await new Promise((r) => setTimeout(r, 0)); + expect(fs.existsSync(fp)).toBe(false); + }); +}); + +describe("hardDeleteNoteFile — sync delete that bypasses the OS-trash remover", () => { + afterEach(() => setPathRemover()); + + it("deletes synchronously even when an async trasher is injected", () => { + writeNoteFile(tmpDir, BASE); + const fp = path.join(tmpDir, "My Project", "My Note.md"); + + // An injected (async, fire-and-forget) trasher must NOT be used for the + // move-internal cleanup — hardDeleteNoteFile always does a sync fs delete so + // the stale old-project copy is gone before the move handler returns. + const trashed: string[] = []; + setPathRemover((t) => { trashed.push(t); return Promise.resolve(); }); + + hardDeleteNoteFile(tmpDir, BASE.projectName!, BASE.id); + + expect(trashed).toEqual([]); // trasher untouched + expect(fs.existsSync(fp)).toBe(false); // file gone synchronously + }); +}); diff --git a/electron/shared/notes-io.ts b/electron/shared/notes-io.ts index bfb7999b9..b3ecddb7b 100644 --- a/electron/shared/notes-io.ts +++ b/electron/shared/notes-io.ts @@ -9,6 +9,58 @@ import fs from "fs"; import matter from "gray-matter"; import { toSlug } from "./text-utils"; +// ── Pluggable path removal (OS trash vs hard delete) ────────────────────── +// +// User-initiated note/project deletes should go to the OS trash/recycle bin so +// they can be restored from Finder/Explorer. Electron exposes `shell.trashItem` +// for this, but it is a main-process-only, async API — and this module is also +// bundled into the standalone MCP process, which has no Electron `shell`. +// +// So we keep the sync fs removal as the default, and let the Electron main +// process inject a trasher via `setPathRemover`. The trasher MAY be async +// (shell.trashItem returns a promise); `removePath` returns that promise so +// callers that need the file to actually be gone before continuing (e.g. the +// move-to-project source cleanup) can await it. Fire-and-forget callers can +// ignore the return — the DB row is already deleted, so the file-watcher can't +// misread the removal as data loss. +export type PathRemover = (targetPath: string, opts: { recursive: boolean }) => void | Promise; + +const hardRemove = (targetPath: string, opts: { recursive: boolean }): void => { + if (opts.recursive) { + fs.rmSync(targetPath, { recursive: true, force: true }); + } else { + fs.unlinkSync(targetPath); + } +}; + +let pathRemover: PathRemover = hardRemove; + +/** + * Override how deleted note files/folders are removed from disk. The Electron + * main process injects an OS-trash implementation (shell.trashItem) so deletes + * are recoverable. Pass no argument to reset to a hard fs delete (used by the + * MCP process, which has no Electron shell). + */ +export function setPathRemover(remover?: PathRemover): void { + pathRemover = remover ?? hardRemove; +} + +/** Remove a file or folder using the active remover; falls back to a hard + * delete if the remover throws (e.g. trashItem unavailable on a headless box). + * Returns a promise that resolves once removal is complete (the remover may be + * async), so callers that must not race a follow-up can await it. */ +function removePath(targetPath: string, opts: { recursive: boolean }): Promise { + if (!fs.existsSync(targetPath)) return Promise.resolve(); + try { + return Promise.resolve(pathRemover(targetPath, opts)).catch(() => { + try { hardRemove(targetPath, opts); } catch { /* ignore */ } + }); + } catch { + try { hardRemove(targetPath, opts); } catch { /* ignore */ } + return Promise.resolve(); + } +} + // ── Cairn-owned frontmatter keys ────────────── // Only these keys are managed by Cairn. All other frontmatter fields // (Obsidian tags, aliases, cssclass, date, publish, etc.) are preserved @@ -395,25 +447,56 @@ export function deleteNoteFile( ): void { const fp = findNoteFilePath(workspacePath, projectName, noteId); if (fp) { - try { fs.unlinkSync(fp); } catch { /* ignore */ } + removePath(fp, { recursive: false }); // Deleting the last note in a subfolder empties it — prune the vacated // folder(s), stopping at the project root. pruneEmptyDirsUpTo(path.dirname(fp), projectNotesDir(workspacePath, projectName)); } } +/** + * Synchronously HARD-delete a note's .md file (bypassing the OS-trash remover). + * + * Use for internal bookkeeping deletes that are not user "delete" actions — e.g. + * removing the stale copy of a note in its OLD project folder after a + * move-to-project. Such a file is a duplicate, not a deletion, so it must NOT + * land in the user's Trash; and it must be gone before the handler returns, or + * the file-watcher could re-import it into the old project (an async trasher + * would race that). A synchronous fs delete guarantees both. + */ +export function hardDeleteNoteFile( + workspacePath: string, + projectName: string, + noteId: string, +): void { + const fp = findNoteFilePath(workspacePath, projectName, noteId); + if (fp && fs.existsSync(fp)) { + try { fs.unlinkSync(fp); } catch { /* ignore */ } + pruneEmptyDirsUpTo(path.dirname(fp), projectNotesDir(workspacePath, projectName)); + } +} + /** * Delete the entire project notes folder (and all .md files inside it). * Called when a project is deleted. + * + * IMPORTANT: the on-disk folder is keyed by the project NAME slug, not the + * project id, and project names are not unique. If another project still + * shares this slug, deleting the folder would destroy that surviving + * project's .md files (data loss). Pass `otherProjectNames` — the names of + * every project that still exists AFTER this one is removed from the DB — so + * we can skip the delete when a sibling still occupies the same folder. */ export function deleteProjectNotesDir( workspacePath: string, projectName: string, + otherProjectNames: string[] = [], ): void { + const slug = toSlug(projectName); + // Guard: never remove a folder that a surviving same-slug project depends on. + if (otherProjectNames.some((n) => toSlug(n) === slug)) return; const dir = projectNotesDir(workspacePath, projectName); - if (fs.existsSync(dir)) { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } - } + removePath(dir, { recursive: true }); } /** diff --git a/scripts/features.config.js b/scripts/features.config.js index fe7e73628..5dbf7dc68 100644 --- a/scripts/features.config.js +++ b/scripts/features.config.js @@ -243,6 +243,19 @@ const FEATURES = [ "Stays Compatible: Your Obsidian properties (tags, aliases, cssclasses, custom fields) are preserved on every save, and `.obsidian`, `.git`, and attachment folders are skipped.", ], }, + { + id: "v2.5.18-custom-commands", + version: "v2.5.18", + title: "Custom Slash Commands", + category: "AI & Chat", + description: "Create your own slash commands for the chat and coding-agent inputs — and see every built-in documented in one place — from the new Settings → Commands page.", + highlights: [ + "Make Your Own: Give a command a name, a description, and the text it inserts, then trigger it by typing `/` in any chat or agent input.", + "Scoped Where You Want: Choose whether each command shows in the AI chat panel, the coding-agent input, or both.", + "Built-ins, Documented: Every command that ships with Cairn is now listed in one place — and a custom command with the same name overrides what a built-in inserts.", + "Browse Community: Install ready-made commands from the cairn-community registry with one click, then tweak them to taste.", + ], + }, ]; module.exports = { FEATURES }; diff --git a/shared/chat/registry-schema.ts b/shared/chat/registry-schema.ts index 6493c8d91..2c4677abe 100644 --- a/shared/chat/registry-schema.ts +++ b/shared/chat/registry-schema.ts @@ -80,11 +80,27 @@ export interface RegistryServiceEntry extends RegistryEntryMeta { }; } +/** + * A community slash command — a reusable prompt/text snippet surfaced in Cairn's + * chat and/or agent input palettes. Installed workspace-globally; never executes + * code, only inserts `insertText` into the input. + */ +export interface RegistryCommandEntry extends RegistryEntryMeta { + definition: { + name: string; + description?: string; + insertText: string; + scope: "chat" | "agent" | "both"; + }; +} + export interface CommunityManifest { version: number; updatedAt: string; mcpServers: RegistryMcpEntry[]; services: RegistryServiceEntry[]; + /** Community slash commands (manifest v2+). Absent on older manifests. */ + commands: RegistryCommandEntry[]; } // ── validation (mirrors cairn-community/schema.json) ──────────────────────── @@ -187,14 +203,25 @@ const entryMeta = { const mcpEntry = z.object({ ...entryMeta, definition: mcpDefinition }).passthrough(); const serviceEntry = z.object({ ...entryMeta, definition: serviceDefinition }).passthrough(); +const commandDefinition = z.object({ + name: z.string().regex(/^[a-z0-9][a-z0-9-]*$/), + description: z.string().optional(), + insertText: z.string().min(1), + scope: z.enum(["chat", "agent", "both"]), +}); + +const commandEntry = z.object({ ...entryMeta, definition: commandDefinition }).passthrough(); + // Manifest-level shape only validates the envelope; entries are validated // individually in parseManifest so ONE bad community entry can't blank the // whole catalog (the reject-all behaviour of z.array(z.object(...)) would). +// `commands` is optional so a pre-v2 manifest (no commands key) still parses. const manifestSchema = z.object({ version: z.number(), updatedAt: z.string(), mcpServers: z.array(z.unknown()), services: z.array(z.unknown()), + commands: z.array(z.unknown()).optional(), }); /** Parse + validate an unknown payload into a CommunityManifest, or throw. */ @@ -212,5 +239,9 @@ export function parseManifest(raw: unknown): CommunityManifest { const r = serviceEntry.safeParse(e); return r.success ? [r.data] : []; }), + commands: (m.commands ?? []).flatMap((e) => { + const r = commandEntry.safeParse(e); + return r.success ? [r.data] : []; + }), } as CommunityManifest; } diff --git a/src/components/agent/AgentChatPane.tsx b/src/components/agent/AgentChatPane.tsx index 6f35c887a..e0cf82dea 100644 --- a/src/components/agent/AgentChatPane.tsx +++ b/src/components/agent/AgentChatPane.tsx @@ -8,7 +8,7 @@ * Multi-turn: each new message continues the same session's history. */ -import { useEffect, useLayoutEffect, useRef, useState, useCallback } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from "react"; import { Trash2, CheckCircle, FileText, Zap, Map as MapIcon } from "lucide-react"; import { QuestionForm } from "@/components/chat/chat-panel/QuestionForm"; import { ChatInput, SuggestionItem } from "@/components/chat/ChatInput"; @@ -16,6 +16,7 @@ import type { PendingQuestion } from "@/hooks/useChatStream"; import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { id } from "@/lib/utils"; +import { getCommandsForScope } from "@/lib/slash-commands"; import { AgentMessageBubble } from "./AgentMessageBubble"; import { PlanTaskList } from "./PlanTaskList"; import { ContextRing } from "./ContextRing"; @@ -83,29 +84,6 @@ function persistPiTranscript(sessionId: string): void { } -const NATIVE_AGENT_SLASH_COMMANDS = [ - { - name: "compact", - description: "Summarise and compact conversation history", - insertText: "/compact", - }, - { - name: "code-review", - description: "Review recent git changes for bugs and style", - insertText: "Please run a code review of the recent changes. Run git diff and analyze it.", - }, - { - name: "spawn-subagent", - description: "Spawn a subagent to work on a task", - insertText: '/spawn-subagent task: "write tests for..."', - }, - { - name: "test", - description: "Run project tests and verify correctness", - insertText: "Run the project tests and report if they pass.", - }, -]; - interface AgentChatPaneProps { session: TerminalSession; isActive: boolean; @@ -141,6 +119,11 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) { projects: s.projects, activeWorkspaceId: s.activeWorkspaceId, }))); + const customCommands = useCairnStore((s) => s.customCommands); + const agentCommands = useMemo( + () => getCommandsForScope("agent", customCommands), + [customCommands] + ); const messages = session.piMessages ?? []; const project = projects.find((p) => p.id === session.projectId); @@ -679,7 +662,7 @@ export function AgentChatPane({ session, isActive }: AgentChatPaneProps) { isLoading={isLoading} disabled={isLoading} placeholder={session.mode === "plan" ? "Describe what you want to build…" : "Ask the agent…"} - commands={NATIVE_AGENT_SLASH_COMMANDS} + commands={agentCommands} onSearchSuggestions={handleSearchFiles} />

diff --git a/src/components/chat/ChatInput.tsx b/src/components/chat/ChatInput.tsx index 274368af2..7b39331c5 100644 --- a/src/components/chat/ChatInput.tsx +++ b/src/components/chat/ChatInput.tsx @@ -63,6 +63,12 @@ export const ChatInput = React.forwardRef( const internalRef = useRef(null); const resolvedRef = (ref as React.RefObject) || internalRef; const fileInputRef = useRef(null); + // After selecting a command/mention we suppress trigger re-scans for a short + // window, otherwise a completed slash command (e.g. "/compact") re-detects the + // '/' at index 0 and re-opens the palette — swallowing the follow-up Enter meant + // to send. A timestamp (not a one-shot bool) covers the multiple events + // (onChange RAF, onKeyUp, onSelect) that fire around a single selection. + const suppressTriggerUntilRef = useRef(0); const [showSuggestions, setShowSuggestions] = useState(false); const [activeIndex, setActiveIndex] = useState(0); @@ -96,6 +102,10 @@ export const ChatInput = React.forwardRef( const checkTrigger = () => { requestAnimationFrame(() => { + // Skip scans briefly after a selection so the palette doesn't immediately reopen. + if (Date.now() < suppressTriggerUntilRef.current) { + return; + } const active = getActiveTrigger(); setTrigger(active); }); @@ -158,8 +168,19 @@ export const ChatInput = React.forwardRef( const before = value.slice(0, trigger.index); const after = value.slice(resolvedRef.current?.selectionEnd ?? value.length); onChange(before + cmd.insertText + after); + // Close the palette and stop trigger scans from reopening it, so a + // completed command is ready to send on the next Enter press. + suppressTriggerUntilRef.current = Date.now() + 250; + setShowSuggestions(false); setTrigger(null); + const newCursorPos = before.length + cmd.insertText.length; resolvedRef.current?.focus(); + setTimeout(() => { + if (resolvedRef.current) { + resolvedRef.current.selectionStart = newCursorPos; + resolvedRef.current.selectionEnd = newCursorPos; + } + }, 0); }; const handleSelectSuggestion = (item: SuggestionItem) => { @@ -168,6 +189,8 @@ export const ChatInput = React.forwardRef( const after = value.slice(resolvedRef.current?.selectionEnd ?? value.length); const insertedText = item.type === "file" ? `\`${item.title}\`` : `[[${item.title}]]`; onChange(before + insertedText + after); + suppressTriggerUntilRef.current = Date.now() + 250; + setShowSuggestions(false); setTrigger(null); resolvedRef.current?.focus(); const newCursorPos = trigger.index + insertedText.length; diff --git a/src/components/chat/chat-panel/index.tsx b/src/components/chat/chat-panel/index.tsx index 4b9a3b416..afb219955 100644 --- a/src/components/chat/chat-panel/index.tsx +++ b/src/components/chat/chat-panel/index.tsx @@ -20,6 +20,7 @@ import { ToolCallIndicator } from "./ToolCallIndicator"; import { QuestionForm } from "./QuestionForm"; import { ChatInput, SuggestionItem } from "../ChatInput"; import { ContextRing } from "@/components/agent/ContextRing"; +import { getCommandsForScope } from "@/lib/slash-commands"; import { cn } from "@/lib/utils"; const GRAPH_SYSTEM_PROMPT = `You are a Knowledge Graph assistant embedded in Cairn, a note-taking and project management app. @@ -53,29 +54,6 @@ You help users build meaningful connections between their notes, tasks, and proj Remember: Suggest connections actively. Call \`suggest_connections\` whenever there is even a potential relationship to explore!`; -const CHAT_SLASH_COMMANDS = [ - { - name: "archive-chat", - description: "Archive conversation as a note & clear chat", - insertText: "/archive-chat", - }, - { - name: "compact", - description: "Summarise and compact conversation history", - insertText: "/compact", - }, - { - name: "board", - description: "Show all task board columns and cards", - insertText: "List the current task board columns and cards.", - }, - { - name: "review-note", - description: "Ask AI to review a note", - insertText: 'Please review my note "[note title]" and suggest improvements.', - }, -]; - interface ChatPanelProps { prefill?: { text: string; autoSend?: boolean } | null; onPrefillConsumed?: () => void; @@ -96,6 +74,7 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP notes, cards, activeChatThreadId, setActiveChatThreadId, setActiveProject, + customCommands, } = useCairnStore(useShallow((s) => ({ chatOpen: s.chatOpen, activeProjectId: s.activeProjectId, @@ -117,12 +96,17 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP activeChatThreadId: s.activeChatThreadId, setActiveChatThreadId: s.setActiveChatThreadId, setActiveProject: s.setActiveProject, + customCommands: s.customCommands, }))); // threadId is driven by the store so the tab bar can switch threads externally const threadId = activeChatThreadId; const [input, setInput] = useState(""); + const chatCommands = useMemo( + () => getCommandsForScope("chat", customCommands), + [customCommands] + ); const [pendingImages, setPendingImages] = useState>([]); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -649,7 +633,7 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP isLoading={isLoading} disabled={isLoading} placeholder={activeView === "graph" ? "Ask about your knowledge graph…" : "Ask about your project…"} - commands={CHAT_SLASH_COMMANDS} + commands={chatCommands} suggestions={mentionSuggestions} pendingImages={pendingImages} onRemoveImage={handleRemoveImage} diff --git a/src/components/notes/note-editor.tsx b/src/components/notes/note-editor.tsx index 614763e54..314e5df2c 100644 --- a/src/components/notes/note-editor.tsx +++ b/src/components/notes/note-editor.tsx @@ -867,7 +867,7 @@ export function NoteEditor({ note, onBack }: NoteEditorProps) {

= { + chat: "Chat", + agent: "Agent", + both: "Chat + Agent", +}; + +const SCOPE_OPTIONS: SlashCommandScope[] = ["both", "chat", "agent"]; + +/** Slash command names are inserted as `/name`; keep them clean + unambiguous. */ +function sanitizeName(raw: string): string { + return raw + .trim() + .toLowerCase() + .replace(/^\/+/, "") + .replace(/\s+/g, "-") + .replace(/[^a-z0-9-]/g, ""); +} + +export function CommandsSettings() { + const { + customCommands, + activeWorkspaceId, + fetchCommands, + createCommand, + updateCommand, + deleteCommand, + } = useCairnStore( + useShallow((s) => ({ + customCommands: s.customCommands, + activeWorkspaceId: s.activeWorkspaceId, + fetchCommands: s.fetchCommands, + createCommand: s.createCommand, + updateCommand: s.updateCommand, + deleteCommand: s.deleteCommand, + })) + ); + + useEffect(() => { + if (activeWorkspaceId) void fetchCommands(activeWorkspaceId); + }, [activeWorkspaceId, fetchCommands]); + + const workspaceCommands = useMemo( + () => customCommands.filter((c) => c.workspaceId === activeWorkspaceId), + [customCommands, activeWorkspaceId] + ); + + const existingNames = useMemo( + () => new Set(workspaceCommands.map((c) => c.name)), + [workspaceCommands] + ); + + const [browseOpen, setBrowseOpen] = useState(false); + + return ( +
+ { + if (!activeWorkspaceId) return; + void createCommand({ workspaceId: activeWorkspaceId, ...input }); + }} + /> + + + {workspaceCommands.length === 0 ? ( +
+ +

No custom commands yet.

+

+ Add one above — type / in a chat to use it. +

+
+ ) : ( +
+ {workspaceCommands.map((cmd) => ( + void updateCommand(cmd.id, patch)} + onDelete={() => void deleteCommand(cmd.id)} + /> + ))} +
+ )} +
+ + +
+ {ALL_BUILTIN_COMMANDS.map((cmd) => ( +
+ + /{cmd.name} + +
+
{cmd.description}
+
+ + {SCOPE_LABEL[cmd.scope]} + +
+ ))} +
+
+ + +
+ +
+ Install ready-made commands from the{" "} + cairn-community registry, then tweak them under + Your commands. +
+ +
+
+ + {browseOpen && setBrowseOpen(false)} />} +
+ ); +} + +// ── Create form ─────────────────────────────── + +function CreateCommandForm({ + disabled, + existingNames, + onCreate, +}: { + disabled: boolean; + existingNames: Set; + onCreate: (input: { + name: string; + description: string; + insertText: string; + scope: SlashCommandScope; + }) => void; +}) { + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [insertText, setInsertText] = useState(""); + const [scope, setScope] = useState("both"); + + const cleanName = sanitizeName(name); + const duplicate = cleanName.length > 0 && existingNames.has(cleanName); + const reserved = cleanName.length > 0 && isReservedCommandName(cleanName); + const canSubmit = cleanName.length > 0 && insertText.trim().length > 0 && !duplicate && !reserved; + + function reset() { + setName(""); + setDescription(""); + setInsertText(""); + setScope("both"); + } + + function submit() { + if (!canSubmit) return; + onCreate({ name: cleanName, description: description.trim(), insertText, scope }); + reset(); + setOpen(false); + } + + if (!open) { + return ( + + ); + } + + return ( +
+
+ New command + +
+ + + + + +