diff --git a/.gitignore b/.gitignore index f19eac1..11f345d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules/ dist/ out/ +.vsix-stage/ .vscode-test/ *.vsix .DS_Store @@ -9,3 +10,5 @@ Thumbs.db .env.* !.env.example coverage/ +asked.txt +shielded.txt \ No newline at end of file diff --git a/.vscodeignore b/.vscodeignore index bf29b4e..e6e924e 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,20 +1,38 @@ +# Source and build tooling (rebuilt by esbuild, never shipped). src/ test/ -.vscode/ -.github/ -docs/ gui/ +docs/ +scripts/ esbuild.js -eslint.config.js + +# Dev config. +.vscode/ +.github/ +.kilo/ +.freebuff/ +.vscode-test.mjs +eslint.config.mjs prettier.config.js vitest.config.ts tsconfig.json tsconfig.gui.json tsconfig.test.json + +# Repo/dev artifacts that must never ship in a VSIX. +.gitignore +.vscodeignore +.vsix-stage/ +pnpm-lock.yaml +AI_PROJECT_CONTEXT.md +CONTRIBUTING.md +asked.txt +shielded.txt + +# Maps are dev-only; the compiled bundles stay. **/*.map **/*.test.ts -node_modules/@cursor/sdk/dist/cjs/** -node_modules/@cursor/sdk/dist/esm/** -node_modules/@cursor/sdk/dist/bundled/** -node_modules/@cursor/sdk/README.md -node_modules/@cursor/sdk/LICENSE.md + +# NOTE: runtime dependencies (node_modules, incl. @cursor/sdk and its +# @cursor/sdk- native packages) are staged and packaged by +# scripts/stage-vsix.js — see that file before changing anything here. diff --git a/AI_PROJECT_CONTEXT.md b/AI_PROJECT_CONTEXT.md index f628666..b8e94da 100644 --- a/AI_PROJECT_CONTEXT.md +++ b/AI_PROJECT_CONTEXT.md @@ -11,7 +11,9 @@ - **What it is:** Spider (package id `codevia-cursor`, VS Code publisher `codevia`) is a VS Code extension providing an AI coding-agent experience - inside the sidebar as a webview view (`codeviaCursor.agent`). + as an **editor-area tab** (Codex / Cline / Kilo style, `spider.openAgent` + / `spider.openAgentEditor`) plus the same agent in the activity-bar + sidebar webview view (`codeviaCursor.agent`). - **Primary purpose:** a coding agent that chats, calls workspace tools (read/write/edit/list/search/move/delete files, run commands), and streams its progress — modeled on Continue / Roo Code / Cline UX. @@ -27,7 +29,10 @@ Settings IA (sidebar sections), Spider watermark empty state** (2026-09-27), **chat-UI redesign: Copy/Delete message actions with stable transcript ids, collapsible tool/command output, live command stdout/stderr streaming** - (2026-09-28). + (2026-09-28), **editor-area Spider tab (Codex/Cline/Kilo style) sharing + one AgentWebviewHost with the sidebar, clickable Spider logo header + button + status-bar item that opens/focuses the tab, History as a real + editor tab** (2026-09-29). - **Identity note:** internal identifiers (`codeviaCursor`, `CodeviaSession`, storage keys `codeviaCursor.*`, package name `codevia-cursor`) intentionally keep the old name. Only user-facing strings say "Spider". Do NOT rename @@ -38,9 +43,10 @@ Actual implemented flow (verified against source): ``` -User (webview GUI, gui/src/main.ts) +User (webview GUI, gui/src/main.ts — sidebar view OR editor tab) ↓ GuiToHost messages (bridge.ts postMessage) -AgentViewProvider (src/webview/agentViewProvider.ts) +AgentWebviewHost (src/webview/agentWebviewHost.ts) ← every surface; OPEN_HISTORY / + OPEN_AGENT_EDITOR (clickable Spider logo) handled here ↓ MessageRouter (src/webview/messageRouter.ts) ← validates every message shape ↓ @@ -570,10 +576,23 @@ views, batches UI syncs (`scheduleUiSync` → one rAF per event burst). `GuiToHost` messages (SEND_PROMPT, NEW_SESSION, SELECT_SESSION, GET_TRANSCRIPT, CONNECT_*, SELECT_*_MODEL, APPROVE/DENY_PERMISSION, SET_RUNTIME_AUTO_APPROVE, GET/SET_PERMISSION_RULE, GET_EXTENSION_INFO, ...) -→ `AgentViewProvider.onDidReceiveMessage` → `MessageRouter.handleMessage` -(result forwarded back if `shouldForwardResult`). Runtime events flow -continuously: `RuntimeManager.onDidPublishEvent` → `toRuntimeExtensionMessage` -→ `webview.postMessage`. +→ `AgentWebviewHost.attach` (sidebar view or editor tab) → +`MessageRouter.handleMessage` (result forwarded back if +`shouldForwardResult`). Runtime events flow continuously: +`RuntimeManager.onDidPublishEvent` → `toRuntimeExtensionMessage` → +`AgentWebviewHost.postMessage` → **every attached surface** (sidebar and +editor tab stay in sync, side by side). + +**Editor-area tab (2026-09-29):** `AgentEditorPanel` is a +`WebviewPanel` (`spider.agentEditor`, tab title "Spider Agent", +`panel.iconPath` = the spider mark) hosting the SAME GUI through the +shared host. The clickable Spider logo in the webview header +(`brand-btn` → `OPEN_AGENT_EDITOR`) and the status-bar Spider item both +call `AgentEditorPanel.createOrShow` (singleton; reveals when open). +`AgentViewProvider` (sidebar) only attaches its webview to the same +host — it owns no webview logic. History is a second `WebviewPanel` +(`HistoryPanel`); picking a conversation loads the transcript in the +editor tab (or focuses the sidebar when no tab is open). ### Execution UI Tool/command lifecycle rendered as exec boxes: `tool_requested → running → diff --git a/esbuild.js b/esbuild.js index 1db5a45..b196e5c 100644 --- a/esbuild.js +++ b/esbuild.js @@ -16,13 +16,18 @@ const extensionOptions = { logLevel: "info", }; +/** + * Two webview clients share one stylesheet: the sidebar Agent view (`main.ts`) + * and the History editor tab (`history.ts`). Multiple entry points require an + * outdir; both land next to the copied HTML/CSS in dist/gui. + */ const guiOptions = { - entryPoints: ["gui/src/main.ts"], + entryPoints: ["gui/src/main.ts", "gui/src/history.ts"], bundle: true, platform: "browser", target: "es2022", format: "iife", - outfile: "dist/gui/main.js", + outdir: "dist/gui", sourcemap: true, logLevel: "info", }; @@ -31,6 +36,7 @@ function copyGuiAssets() { const outDir = path.join("dist", "gui"); fs.mkdirSync(outDir, { recursive: true }); fs.copyFileSync(path.join("gui", "index.html"), path.join(outDir, "index.html")); + fs.copyFileSync(path.join("gui", "history.html"), path.join(outDir, "history.html")); fs.copyFileSync(path.join("gui", "styles", "main.css"), path.join(outDir, "main.css")); // Spider mark for the empty-chat watermark (single asset, no duplication). fs.copyFileSync(path.join("assets", "spider-icon.png"), path.join(outDir, "spider-icon.png")); diff --git a/gui/history.html b/gui/history.html new file mode 100644 index 0000000..690908d --- /dev/null +++ b/gui/history.html @@ -0,0 +1,33 @@ + + + + + + + + Spider History + + + + +
+
+

Conversation history

+

Select a conversation to open it in the sidebar. The preview is read-only.

+
+
+
+
+ +
+
+
+

Select a conversation to preview it.

+
+
+
+ + + + diff --git a/gui/index.html b/gui/index.html index 54799a9..b1a3c91 100644 --- a/gui/index.html +++ b/gui/index.html @@ -4,42 +4,77 @@ + + Spider
+
-
Spider
-
Cursor
- + + +
+ Cursor +
+ +
+ + + + + + + +
-
+
+
-
- + + + \ No newline at end of file diff --git a/gui/src/components/sessionBar.ts b/gui/src/components/sessionBar.ts deleted file mode 100644 index 75bb6a7..0000000 --- a/gui/src/components/sessionBar.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Conversation bar above the transcript. - * - * History navigation was removed from the chat UI (no dropdown, no History - * page): conversations are still persisted by SessionStore/TranscriptStore on - * the host, but the chat header only exposes starting a NEW conversation. - */ -export interface SessionBarHandle { - update(disabled: boolean): void; -} - -export function createSessionBar( - root: HTMLElement, - handlers: { - onCreate: () => void; - }, -): SessionBarHandle { - root.replaceChildren(); - - const create = document.createElement("button"); - create.className = "btn btn-ghost"; - create.type = "button"; - create.textContent = "New conversation"; - create.setAttribute("aria-label", "Start a new conversation"); - create.addEventListener("click", handlers.onCreate); - - root.append(create); - - return { - update(disabled) { - create.disabled = disabled; - }, - }; -} diff --git a/gui/src/history.ts b/gui/src/history.ts new file mode 100644 index 0000000..131f1c0 --- /dev/null +++ b/gui/src/history.ts @@ -0,0 +1,248 @@ +import { onHostMessage, postToHost } from "./bridge"; +import type { SessionListItem } from "./protocol"; + +/** + * History editor tab client. + * + * Read-only over host-authoritative state: it renders the conversation list, + * asks the host to switch the active conversation (which loads the transcript + * in the sidebar Agent view), previews that transcript here, and can delete a + * conversation. Deletion is durable on the host — the row disappears only + * because the host re-broadcasts the list without it. + */ + +type TranscriptEntry = { + id?: string; + kind: "user" | "assistant" | "thinking" | "tool" | "command" | "error" | "system"; + text: string; + timestamp: number; + toolName?: string; + command?: string; + path?: string; + stdout?: string; + stderr?: string; + exitCode?: number | null; + error?: string; +}; + +const TERMINAL_STATUSES = new Set(["COMPLETED", "FAILED", "CANCELLED", "DISCONNECTED"]); +/** Per-entry cap so a pathological transcript cannot build a huge DOM. */ +const MAX_ENTRY_CHARS = 2000; + +const listRoot = mustEl("history-list"); +const emptyState = mustEl("history-empty"); +const previewBody = mustEl("history-preview-body"); +const previewEmpty = mustEl("history-preview-empty"); + +let activeSessionId: string | undefined; +/** Conversation whose transcript is shown in the preview pane. */ +let previewSessionId: string | undefined; +const statusBySession = new Map(); + +function render(sessions: readonly SessionListItem[]): void { + const sorted = [...sessions].sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); + + // The previewed conversation may have been deleted (here or elsewhere). + if (previewSessionId && !sorted.some((session) => session.sessionId === previewSessionId)) { + previewSessionId = undefined; + renderPreview(undefined, []); + } + + const rows = sorted.map(renderRow); + listRoot.replaceChildren(...rows); + emptyState.hidden = rows.length > 0; +} + +function renderRow(session: SessionListItem): HTMLElement { + const row = document.createElement("div"); + row.className = "history-row"; + row.setAttribute("role", "listitem"); + row.dataset.sessionId = session.sessionId; + row.classList.toggle("is-active", session.sessionId === activeSessionId); + row.classList.toggle("is-previewing", session.sessionId === previewSessionId); + + const main = document.createElement("button"); + main.type = "button"; + main.className = "history-row-main"; + main.setAttribute("aria-label", `Open conversation: ${session.currentTask ?? session.sessionId}`); + + const title = document.createElement("span"); + title.className = "history-row-title"; + title.textContent = session.currentTask?.trim() || "Untitled conversation"; + + const meta = document.createElement("span"); + meta.className = "history-row-meta"; + const workspace = basename(session.workspacePath); + const updated = formatUpdated(session.updatedAt); + meta.textContent = [workspace, updated].filter(Boolean).join(" · "); + + const status = document.createElement("span"); + status.className = "history-row-status"; + status.dataset.status = session.status; + status.textContent = session.status.toLowerCase(); + + main.append(title, meta, status); + main.addEventListener("click", () => openConversation(session.sessionId)); + + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "history-row-delete"; + remove.textContent = "Delete"; + remove.title = "Delete this conversation and its transcript"; + remove.setAttribute("aria-label", `Delete conversation: ${session.currentTask ?? session.sessionId}`); + remove.addEventListener("click", (event) => { + event.stopPropagation(); + deleteConversation(session.sessionId); + }); + + row.append(main, remove); + return row; +} + +/** Open in the sidebar and preview here (one click does both). */ +function openConversation(sessionId: string): void { + previewSessionId = sessionId; + postToHost({ type: "SELECT_SESSION", sessionId }); + requestTranscript(sessionId); + markPreviewedRow(); +} + +function deleteConversation(sessionId: string): void { + if (previewSessionId === sessionId) { + previewSessionId = undefined; + renderPreview(undefined, []); + } + postToHost({ type: "DELETE_SESSION", sessionId }); +} + +function requestTranscript(sessionId: string): void { + renderPreview(sessionId, []); + postToHost({ type: "GET_TRANSCRIPT", sessionId }); +} + +function renderPreview(sessionId: string | undefined, entries: readonly TranscriptEntry[]): void { + const showEmpty = sessionId === undefined || entries.length === 0; + previewEmpty.hidden = !showEmpty; + previewEmpty.textContent = sessionId === undefined + ? "Select a conversation to preview it." + : "This conversation has no messages yet."; + previewBody.replaceChildren(...(showEmpty ? [] : entries.map(renderEntry))); +} + +function renderEntry(entry: TranscriptEntry): HTMLElement { + const el = document.createElement("div"); + el.className = `preview-entry preview-${entry.kind}`; + + const label = document.createElement("span"); + label.className = "preview-entry-label"; + label.textContent = entryLabel(entry); + el.appendChild(label); + + const body = document.createElement("span"); + body.className = "preview-entry-text"; + body.textContent = truncate(entryText(entry)); + el.appendChild(body); + return el; +} + +function entryLabel(entry: TranscriptEntry): string { + switch (entry.kind) { + case "user": + return "You"; + case "assistant": + return "Spider"; + case "thinking": + return "Thinking"; + case "tool": + return entry.toolName ?? "Tool"; + case "command": + return "Command"; + case "error": + return "Error"; + default: + return "System"; + } +} + +function entryText(entry: TranscriptEntry): string { + switch (entry.kind) { + case "command": { + const output = [entry.stdout, entry.stderr].filter((part) => Boolean(part?.trim())).join("\n"); + const exit = entry.exitCode === undefined || entry.exitCode === null ? "" : ` (exit ${entry.exitCode})`; + return `${entry.command ?? ""}${exit}${output ? `\n${output}` : ""}`; + } + case "tool": { + const target = entry.path ? ` ${entry.path}` : ""; + return entry.error ? `${entry.error}` : `${entry.text || `Used ${entry.toolName ?? "tool"}`}${target}`; + } + default: + return entry.text; + } +} + +function truncate(text: string): string { + return text.length > MAX_ENTRY_CHARS ? `${text.slice(0, MAX_ENTRY_CHARS)}…` : text; +} + +function markPreviewedRow(): void { + for (const row of Array.from(listRoot.querySelectorAll(".history-row"))) { + row.classList.toggle("is-previewing", row.dataset.sessionId === previewSessionId); + } +} + +function basename(fsPath: string): string { + const parts = fsPath.split(/[\\/]/).filter((part) => part.length > 0); + return parts[parts.length - 1] ?? fsPath; +} + +function formatUpdated(updatedAt: number | undefined): string { + if (updatedAt === undefined) { + return ""; + } + const diffMs = Date.now() - updatedAt; + const minutes = Math.round(diffMs / 60000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return new Date(updatedAt).toLocaleDateString(); +} + +onHostMessage((message) => { + switch (message.type) { + case "SESSION_UPDATED": { + const previous = new Map(statusBySession); + statusBySession.clear(); + for (const session of message.sessions) { + statusBySession.set(session.sessionId, session.status); + } + // A run that just finished changed the transcript: refresh the preview. + // (Streaming updates are ignored — the sidebar already shows those live.) + if (previewSessionId) { + const before = previous.get(previewSessionId); + const now = statusBySession.get(previewSessionId); + if (now && before && before !== now && TERMINAL_STATUSES.has(now)) { + postToHost({ type: "GET_TRANSCRIPT", sessionId: previewSessionId }); + } + } + activeSessionId = message.activeSessionId; + render(message.sessions); + break; + } + case "TRANSCRIPT": + if (message.sessionId === previewSessionId) { + renderPreview(message.sessionId, message.entries); + } + break; + default: + break; + } +}); + +postToHost({ type: "LIST_SESSIONS" }); + +function mustEl(id: string): HTMLElement { + const el = document.getElementById(id); + if (!el) throw new Error(`Missing element #${id}`); + return el; +} diff --git a/gui/src/main.ts b/gui/src/main.ts index 2d5e828..5032021 100644 --- a/gui/src/main.ts +++ b/gui/src/main.ts @@ -3,7 +3,6 @@ import type { HostToGui, RuntimeProvider, SessionListItem, PermissionRule, Permi import { AppState, ChatLine, createInitialState, phaseFromAgentState, type SettingsSection } from "./state"; import { createComposer } from "./components/composer"; import { createMessageList } from "./components/messageList"; -import { createSessionBar } from "./components/sessionBar"; import { createQuestionPrompt, createTodoPanel } from "./components/agentPanels"; import { renderChatView } from "./views/chatView"; import { renderSettingsView } from "./views/settingsView"; @@ -30,8 +29,11 @@ function isStaleTranscript(sessionId: string): boolean { } const runtimePill = mustEl("runtime-pill"); +const brandBtn = mustEl("brand-btn") as HTMLButtonElement; const settingsBtn = mustEl("settings-btn") as HTMLButtonElement; const settingsBack = mustEl("settings-back") as HTMLButtonElement; +const historyBtn = mustEl("history-btn") as HTMLButtonElement; +const newSessionBtn = mustEl("new-session-btn") as HTMLButtonElement; const setupBanner = mustEl("setup-banner"); const settingsView = mustEl("settings-view"); const chatView = mustEl("chat-view"); @@ -39,7 +41,6 @@ const providerSettings = mustEl("provider-settings"); const authFeedback = mustEl("auth-feedback"); const messageListRoot = mustEl("message-list"); const composerRoot = mustEl("composer"); -const sessionBarRoot = mustEl("session-bar"); const messageList = createMessageList(messageListRoot, { onAllowPermission: (requestId) => resolvePermission(requestId, "ALLOW"), @@ -57,9 +58,6 @@ const composer = createComposer(composerRoot, { onModelSelect: selectModelFromComposer, onToggleAutoApprove: toggleAutoApprove, }); -const sessionBar = createSessionBar(sessionBarRoot, { - onCreate: () => startNewConversation(), -}); // Phase 3 surfaces: a task-plan panel and an ask_user prompt. Both mirror // host-authoritative state; the components own their own DOM. @@ -83,8 +81,20 @@ settingsBack.addEventListener("click", () => { state.view = "chat"; render(); }); +// New conversation lives as a "+" in the header (top-right); History opens a +// dedicated editor tab rather than occupying sidebar space. +newSessionBtn.addEventListener("click", () => startNewConversation()); +historyBtn.addEventListener("click", () => postToHost({ type: "OPEN_HISTORY" })); +// Clicking the Spider logo opens (or focuses) the Spider editor tab. +brandBtn.addEventListener("click", () => postToHost({ type: "OPEN_AGENT_EDITOR" })); onHostMessage(handleHostMessage); +// The host carries a pending "show Settings" request in the HTML +// (meta tag) so a freshly created webview opens in Settings even +// before the first postMessage round-trip. +if (document.querySelector('meta[name="spider-show-settings"]')?.getAttribute("content") === "1") { + state.view = "settings"; +} postToHost({ type: "GET_AUTH_STATUS" }); postToHost({ type: "GET_RUNTIME_STATUS" }); postToHost({ type: "LIST_SESSIONS" }); @@ -474,7 +484,7 @@ function render(): void { if (!showSettings) { renderSetupBanner(); - sessionBar.update(state.running); + newSessionBtn.disabled = state.running; renderChatView( { composer }, state, diff --git a/gui/src/protocol.ts b/gui/src/protocol.ts index 4e3ed67..0c2ab09 100644 --- a/gui/src/protocol.ts +++ b/gui/src/protocol.ts @@ -36,6 +36,12 @@ export type GuiToHost = | { type: "SELECT_OPENROUTER_MODEL"; modelId: string } | { type: "GET_AUTH_STATUS" } | { type: "LIST_SESSIONS" } + /** Opens the conversation History editor tab. */ + | { type: "OPEN_HISTORY" } + /** Clicking the Spider logo opens (or focuses) the Spider editor tab. */ + | { type: "OPEN_AGENT_EDITOR" } + /** History tab: permanently removes a conversation and its transcript. */ + | { type: "DELETE_SESSION"; sessionId: string } | { type: "GET_TRANSCRIPT"; sessionId: string } | { type: "GET_RUNTIME_STATUS" } | { type: "SELECT_RUNTIME"; provider: RuntimeProvider; modelId?: string } diff --git a/gui/styles/main.css b/gui/styles/main.css index 220fd29..4fc05ef 100644 --- a/gui/styles/main.css +++ b/gui/styles/main.css @@ -37,22 +37,41 @@ input:focus, select:focus, textarea:focus { #app { display: flex; flex-direction: column; height: 100%; min-width: 0; } .app-header { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); min-height: 42px; } -.brand { font-weight: 600; letter-spacing: .02em; } +/* Clickable Spider brand: the logo opens (or focuses) the Spider + editor tab from any surface — sidebar or editor. */ +.brand { display: inline-flex; align-items: center; gap: 7px; background: transparent; border: 0; color: var(--fg); padding: 2px 5px; cursor: pointer; font: inherit; font-weight: 600; letter-spacing: .02em; flex-shrink: 0; } +.brand:hover { color: var(--accent-soft); background: var(--user-bg); } +.brand:active { color: var(--accent); } +.brand-logo { width: 18px; height: 18px; object-fit: contain; flex-shrink: 0; } +.brand-name { user-select: none; } .runtime-pill { flex: 1; min-width: 0; color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .icon-btn { border: 0; background: transparent; color: var(--muted); padding: 4px 7px; cursor: pointer; } .icon-btn:hover { color: var(--accent-soft); background: var(--user-bg); } .icon-btn:active { color: var(--accent); } +.icon-btn:disabled { opacity: .45; cursor: default; } +/* Header actions hug the top-right corner; the runtime pill flexes to push + them there, so no absolute positioning is needed. */ +.header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; } +.icon-btn-plus { font-size: 16px; font-weight: 600; line-height: 1; padding: 3px 9px; } .view { display: flex; flex-direction: column; flex: 1; min-height: 0; padding: 10px; gap: 8px; } .view[hidden] { display: none; } +/* Editor-tab layout: keep the conversation and settings in a centered + reading column when the surface is wide (the Codex / Cline / Kilo + editor-view look). The narrow sidebar is unaffected because it is + smaller than the column, so the rule only changes wide surfaces. */ +#chat-view > *, +.settings-view > * { + margin-inline: auto; + max-width: 760px; + width: 100%; +} + .setup-banner:not([hidden]) { display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--border); padding: 12px; background: var(--card-bg); } .setup-banner span { color: var(--muted); line-height: 1.4; } .setup-banner .btn { align-self: flex-start; margin-top: 3px; } -/* Conversation bar: New-conversation only (history navigation lives nowhere in - the chat UI; sessions are still persisted by the host). */ -.session-bar { display: flex; align-items: center; gap: 6px; min-width: 0; } .message-list { flex: 1; min-height: 0; overflow: auto; padding: 4px 2px; } .empty-chat { height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 6px; text-align: center; color: var(--muted); } @@ -160,13 +179,16 @@ input:focus, select:focus, textarea:focus { .composer-input { position: relative; display: block; } .composer-input textarea { width: 100%; min-height: 76px; max-height: 1000px; resize: vertical; - /* Bottom padding reserves the action row so text never runs under it. */ - padding: 5px 5px 34px; border: 1px solid transparent; outline: none; + /* Top padding reserves the action row (Send sits in the top-right corner) + so text never runs under the buttons. */ + padding: 34px 5px 5px; border: 1px solid transparent; outline: none; background: transparent; color: var(--input-fg); } .composer-input textarea:focus { border-color: transparent; box-shadow: none; } .composer-actions { - position: absolute; right: 4px; bottom: 4px; + /* Anchored to the TOP-right corner of the input area: Send (and Cancel / + Try Again) stay beside the first line of text, not below the textarea. */ + position: absolute; right: 4px; top: 4px; display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: nowrap; max-width: calc(100% - 8px); } @@ -174,7 +196,7 @@ input:focus, select:focus, textarea:focus { /* Narrow VS Code sidebars: let the action row wrap onto a second line and reserve the matching vertical space in the textarea. */ @media (max-width: 340px) { - .composer-input textarea { padding-bottom: 62px; } + .composer-input textarea { padding-top: 62px; } .composer-actions { flex-wrap: wrap; } } @@ -320,3 +342,56 @@ h2 { font-size: 13px; margin-bottom: 4px; } .question-input { width: 100%; padding: 7px 8px; background: var(--input-bg); color: var(--input-fg); border: 1px solid var(--border); } .question-actions { display: flex; gap: 6px; justify-content: flex-end; } .question-overlay[hidden] { display: none; } + +/* History editor tab (gui/history.html). Shares the theme variables above so + it matches the sidebar without duplicating a stylesheet. */ +.history-body { background: var(--vscode-editor-background, var(--bg)); } +#history-app { max-width: 1100px; margin: 0 auto; padding: 20px 18px 32px; display: flex; flex-direction: column; gap: 12px; } +.history-header h1 { margin: 0 0 4px; font-size: 17px; font-weight: 600; } +.history-header .hint { margin: 0; color: var(--muted); } + +/* Master/detail: conversation list left, read-only transcript right. */ +.history-layout { display: grid; grid-template-columns: minmax(240px, 320px) 1fr; gap: 14px; align-items: start; } +.history-list-pane { display: flex; flex-direction: column; gap: 6px; min-width: 0; } +.history-list { display: flex; flex-direction: column; gap: 6px; } +.history-row { + display: flex; align-items: stretch; gap: 2px; + background: var(--card-bg); border: 1px solid var(--border); +} +.history-row:hover { border-color: var(--accent); } +.history-row.is-active { border-left: 3px solid var(--accent); } +.history-row.is-previewing { background: var(--user-bg); } +.history-row-main { + display: grid; grid-template-columns: 1fr auto; grid-template-areas: "title status" "meta status"; + gap: 2px 10px; flex: 1; min-width: 0; text-align: left; padding: 9px 11px; cursor: pointer; + background: transparent; color: var(--fg); border: 0; +} +.history-row-title { grid-area: title; font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.history-row-meta { grid-area: meta; font-size: 11px; color: var(--muted); } +.history-row-status { grid-area: status; align-self: center; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.history-row-status[data-status="RUNNING"], .history-row-status[data-status="READY"], .history-row-status[data-status="STARTING"] { color: var(--vscode-testing-iconPassed, #73c991); } +.history-row-status[data-status="FAILED"], .history-row-status[data-status="CANCELLED"] { color: var(--error); } +.history-row-delete { + flex-shrink: 0; align-self: stretch; padding: 0 10px; cursor: pointer; + background: transparent; color: var(--muted); border: 0; border-left: 1px solid var(--border); font-size: 11px; +} +.history-row-delete:hover { color: var(--accent-contrast); background: var(--error); } + +.history-preview { position: sticky; top: 0; min-width: 0; max-height: calc(100vh - 130px); overflow: auto; + padding: 10px 12px; background: var(--card-bg); border: 1px solid var(--border); } +.history-preview-body { display: flex; flex-direction: column; gap: 8px; } +.preview-entry { display: flex; flex-direction: column; gap: 2px; padding-left: 8px; border-left: 2px solid var(--border); } +.preview-entry-label { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); } +.preview-entry-text { white-space: pre-wrap; word-break: break-word; font-size: 12px; } +.preview-user { border-left-color: var(--accent-soft); } +.preview-assistant { border-left-color: var(--accent); } +.preview-error { border-left-color: var(--error); } +.preview-thinking .preview-entry-text { color: var(--muted); font-style: italic; } + +.history-empty { color: var(--muted); } +.history-empty[hidden] { display: none; } + +@media (max-width: 720px) { + .history-layout { grid-template-columns: 1fr; } + .history-preview { position: static; max-height: none; } +} diff --git a/package.json b/package.json index ba64328..7e520c9 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "codevia-cursor", + "name": "spider", "displayName": "Spider", "version": "0.1.0", "publisher": "Ahmadpasha1221", - "description": "A production-quality VS Code AI coding agent using the official Cursor TypeScript SDK.", + "description": "An autonomous AI coding agent for VS Code. Use your own model through Ollama, OpenAI-compatible servers, OpenRouter, or Cursor agents, with workspace tools, permissions, streaming, planning, and verification.", "license": "MIT", "engines": { "vscode": "^1.95.0", @@ -14,11 +14,15 @@ "Machine Learning" ], "keywords": [ - "cursor", "ai", - "agent", "coding", - "cursor-sdk" + "agent", + "code assistant", + "ollama", + "openrouter", + "openai", + "local ai", + "cursor" ], "icon": "assets/icon.png", "galleryBanner": { @@ -37,14 +41,17 @@ "ui" ], "activationEvents": [ - "onView:codeviaCursor.agent", - "onCommand:codeviaCursor.openAgent", - "onCommand:codeviaCursor.openSettings" + "onView:spider.agent", + "onCommand:spider.openAgent", + "onCommand:spider.openAgentEditor", + "onCommand:spider.openSettings", + "onCommand:spider.openHistory", + "onChatParticipant:spider.spider" ], "authentication": { "providers": [ { - "id": "codeviaCursor", + "id": "spider", "label": "Spider", "supportsMultipleAccounts": false } @@ -54,7 +61,7 @@ "viewsContainers": { "activitybar": [ { - "id": "codeviaCursor", + "id": "spider", "title": "Spider", "icon": "assets/spider-icon.png" } @@ -62,26 +69,36 @@ }, "chatParticipants": [ { - "id": "codeviaCursor.spider", + "id": "spider.spider", "name": "spider", "fullName": "Spider", - "description": "AI coding agent powered by Cursor SDK", + "description": "Autonomous AI coding agent for your VS Code workspace.", "isSticky": true, "commands": [ - { "name": "explain", "description": "Explain selected code" }, - { "name": "fix", "description": "Fix issues in selection" }, - { "name": "test", "description": "Generate tests" } + { + "name": "explain", + "description": "Explain selected code" + }, + { + "name": "fix", + "description": "Fix issues in selection" + }, + { + "name": "test", + "description": "Generate tests" + } ] } - ],"configuration": { + ], + "configuration": { "title": "Spider", "properties": { - "codeviaCursor.model": { + "spider.model": { "type": "string", "default": "", "description": "Model used by the Spider agent." }, - "codeviaCursor.autoApply": { + "spider.autoApply": { "type": "boolean", "default": false, "description": "Automatically apply edits from the agent." @@ -89,10 +106,10 @@ } }, "views": { - "codeviaCursor": [ + "spider": [ { "type": "webview", - "id": "codeviaCursor.agent", + "id": "spider.agent", "name": "Agent", "icon": "assets/icon.png" } @@ -100,14 +117,24 @@ }, "commands": [ { - "command": "codeviaCursor.openAgent", + "command": "spider.openAgent", "title": "Open Agent", "category": "Spider" }, { - "command": "codeviaCursor.openSettings", + "command": "spider.openAgentEditor", + "title": "Open Agent Editor", + "category": "Spider" + }, + { + "command": "spider.openSettings", "title": "Open Settings", "category": "Spider" + }, + { + "command": "spider.openHistory", + "title": "Open History", + "category": "Spider" } ] }, @@ -119,18 +146,19 @@ "lint": "eslint src test gui --max-warnings 0", "format": "prettier --write .", "test": "vitest run", + "test:vsix": "node scripts/vsixSmokeTest.mjs", "test:integration": "vscode-test", "vscode:prepublish": "pnpm run typecheck && pnpm run lint && pnpm run compile", - "package": "vsce package --no-dependencies" + "package": "pnpm run vscode:prepublish && node scripts/stage-vsix.js" }, "dependencies": { - "@cursor/sdk": "1.0.31" + "@cursor/sdk": "1.0.35" }, "devDependencies": { + "@eslint/js": "^9.39.5", "@types/mocha": "^10.0.0", "@types/node": "^22.13.0", "@types/vscode": "^1.95.0", - "@eslint/js": "^9.39.5", "@typescript-eslint/eslint-plugin": "^8.22.0", "@typescript-eslint/parser": "^8.22.0", "@typescript-eslint/utils": "^8.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5820a39..0ff9836 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@cursor/sdk': - specifier: 1.0.31 - version: 1.0.31 + specifier: 1.0.35 + version: 1.0.35 devDependencies: '@eslint/js': specifier: ^9.39.5 @@ -128,13 +128,6 @@ packages: '@bufbuild/protobuf@1.10.0': resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} - '@connectrpc/connect-node@1.7.0': - resolution: {integrity: sha512-6vaPIkG/NyhxlYgytLoR9KYbPhczEboFB2OYWkA9qvUz1K7efXfeGrlRxoLtpa+r8VxyIOw73w5ktNe743nD+A==} - engines: {node: '>=16.0.0'} - peerDependencies: - '@bufbuild/protobuf': ^1.10.0 - '@connectrpc/connect': 1.7.0 - '@connectrpc/connect-web@1.7.0': resolution: {integrity: sha512-qyP0YOnUPRWwCc/VfsoydMJvkb7EyUPr2q9sHgBuJzbADjiqck1gKH5V5ZPzPhTLBvmz5UvG+wiZ5sMRQHU1MQ==} peerDependencies: @@ -146,38 +139,38 @@ packages: peerDependencies: '@bufbuild/protobuf': ^1.10.0 - '@cursor/sdk-darwin-arm64@1.0.31': - resolution: {integrity: sha512-i6INDIQhV7xDeFKfND0yr/SOwv4uJthPMfXXvNpZapO+IbJTx30aPzJVMBqdksvDHyn/cekJB/92Jkz+a5K8Ow==} + '@cursor/sdk-darwin-arm64@1.0.35': + resolution: {integrity: sha512-q9/y2NHTGnE0iwMfJ+FIHmj2gDGDRy45fH3rF3SAFGEGzM4xh726r64n6hH4OQYwKhcMqhqxbPGBpXHd5lRW4A==} cpu: [arm64] os: [darwin] hasBin: true - '@cursor/sdk-darwin-x64@1.0.31': - resolution: {integrity: sha512-vCmrGykwflJNAAr4RVY4UlZ+F0fmUF+WCR8zFzP4Jl1Da2b8Oh1rxrVLbIbleS1/LKsX/OPbTQNRL0SEER95Sg==} + '@cursor/sdk-darwin-x64@1.0.35': + resolution: {integrity: sha512-0IGwzNr3Lq9IrcIRU4AlT7kTkwwVhRpQyjHqhvnKdgtn+WFYShr9gqTb44leihi/Cbf7a76yEdCgpfScBCPtxQ==} cpu: [x64] os: [darwin] hasBin: true - '@cursor/sdk-linux-arm64@1.0.31': - resolution: {integrity: sha512-BHTwumfhWjTy0k41+KaaXfTm3MGu6WEYU76FmXqc/r1+ynuQroqfBKWyfH4XBcx3uzyaZJQLxNB9BBbs8yHUig==} + '@cursor/sdk-linux-arm64@1.0.35': + resolution: {integrity: sha512-AAx2DBqOc21kIwKuHRLl+4bCbuU7Su/BWY+TzXMgMsxAOHvLDGg7SGVvHOMV4ENczbOEcO4zc3XDbmQB4x3rdw==} cpu: [arm64] os: [linux] hasBin: true - '@cursor/sdk-linux-x64@1.0.31': - resolution: {integrity: sha512-y+ahiKQvEISUn9y6z75jwKjLQkrJ/nY7ehL1Vi9X8zdbjz14fiEtMTICAHccTiTq5cf6dZjXC5QfPdJRat6dhg==} + '@cursor/sdk-linux-x64@1.0.35': + resolution: {integrity: sha512-3PABXvbT/Ue3jN+ytWcNDPBWptAiJv4qzxL0m2rma3e7kyx/Q1/8FYRYunIpX21HfPGgDSURlWztEov1xdZs3Q==} cpu: [x64] os: [linux] hasBin: true - '@cursor/sdk-win32-x64@1.0.31': - resolution: {integrity: sha512-5ti4AwUz8kh5ovM86K/fTiHmVGL6jk1faQkTCX08lcCxFSlUDwpeyhn90pWsbNFQ4Vy2PttqEjanV6895p13eA==} + '@cursor/sdk-win32-x64@1.0.35': + resolution: {integrity: sha512-zADtORY6QRMCBTckxTieAKxcthRiefNNHxVhX2RtO3pQGa4iQwTrHjmjUw0nqCF6sJlCrQ58DTgkrz+BZhLEcw==} cpu: [x64] os: [win32] hasBin: true - '@cursor/sdk@1.0.31': - resolution: {integrity: sha512-0SdJQqp5oXn81oJqIVkLpgHih+CL6CAudK83pCsdJyA23AvInSWlMaGpLi+JlrK3efHoswiEhhASs9WpeEz3QQ==} + '@cursor/sdk@1.0.35': + resolution: {integrity: sha512-CJQR4ocRFm74N1miSqSSrhWyEbvTRIJGyMfHQvZlcBIYzLMXCVPmb1bV82L4NJ8mG9TyvbckEjERjbnzpG5akQ==} engines: {node: '>=22.13'} '@esbuild/aix-ppc64@0.21.5': @@ -512,10 +505,6 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@fastify/busboy@2.1.1': - resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} - engines: {node: '>=14'} - '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -2391,10 +2380,6 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@5.29.0: - resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} - engines: {node: '>=14.0'} - undici@7.29.1: resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} @@ -2679,12 +2664,6 @@ snapshots: '@bufbuild/protobuf@1.10.0': {} - '@connectrpc/connect-node@1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0))': - dependencies: - '@bufbuild/protobuf': 1.10.0 - '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) - undici: 5.29.0 - '@connectrpc/connect-web@1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0))': dependencies: '@bufbuild/protobuf': 1.10.0 @@ -2694,35 +2673,34 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.0 - '@cursor/sdk-darwin-arm64@1.0.31': + '@cursor/sdk-darwin-arm64@1.0.35': optional: true - '@cursor/sdk-darwin-x64@1.0.31': + '@cursor/sdk-darwin-x64@1.0.35': optional: true - '@cursor/sdk-linux-arm64@1.0.31': + '@cursor/sdk-linux-arm64@1.0.35': optional: true - '@cursor/sdk-linux-x64@1.0.31': + '@cursor/sdk-linux-x64@1.0.35': optional: true - '@cursor/sdk-win32-x64@1.0.31': + '@cursor/sdk-win32-x64@1.0.35': optional: true - '@cursor/sdk@1.0.31': + '@cursor/sdk@1.0.35': dependencies: '@bufbuild/protobuf': 1.10.0 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) - '@connectrpc/connect-node': 1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)) '@connectrpc/connect-web': 1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)) '@statsig/js-client': 3.31.0 zod: 3.25.76 optionalDependencies: - '@cursor/sdk-darwin-arm64': 1.0.31 - '@cursor/sdk-darwin-x64': 1.0.31 - '@cursor/sdk-linux-arm64': 1.0.31 - '@cursor/sdk-linux-x64': 1.0.31 - '@cursor/sdk-win32-x64': 1.0.31 + '@cursor/sdk-darwin-arm64': 1.0.35 + '@cursor/sdk-darwin-x64': 1.0.35 + '@cursor/sdk-linux-arm64': 1.0.35 + '@cursor/sdk-linux-x64': 1.0.35 + '@cursor/sdk-win32-x64': 1.0.35 '@esbuild/aix-ppc64@0.21.5': optional: true @@ -2917,8 +2895,6 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 - '@fastify/busboy@2.1.1': {} - '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -4976,10 +4952,6 @@ snapshots: undici-types@6.21.0: {} - undici@5.29.0: - dependencies: - '@fastify/busboy': 2.1.1 - undici@7.29.1: {} unicorn-magic@0.1.0: {} diff --git a/scripts/stage-vsix.js b/scripts/stage-vsix.js new file mode 100644 index 0000000..afddedd --- /dev/null +++ b/scripts/stage-vsix.js @@ -0,0 +1,240 @@ +#!/usr/bin/env node +/** + * Stage a production VSIX payload for Spider and package it with vsce. + * + * Why staging exists (root cause of the old "Cannot find module '@cursor/sdk'"): + * - vsce's root file glob always ignores `node_modules/**` (hardcoded in + * collectAllFiles), so `vsce package --no-dependencies` never ships runtime + * dependencies at all. + * - vsce's dependency detection (`--dependencies` mode) shells out to + * `npm list --production`, which fails in this pnpm checkout because + * `@cursor/sdk` carries monorepo-internal devDependencies (@anysphere/*) + * that no registry install can satisfy. + * - `@cursor/sdk` must stay external (it cannot be bundled: it resolves + * platform-native packages, vendors native binaries, and depends on layout). + * + * The fix: assemble a clean package directory with a REAL (non-symlink) + * node_modules containing the SDK, its production dependency closure, and ALL + * of its `@cursor/sdk-` native packages (npm only installs the + * os/cpu-matching one, so the rest are fetched from the registry here). vsce's + * npm detection then succeeds inside the stage and the VSIX ships with a + * self-contained runtime that resolves on Windows/macOS/Linux. + * + * Output: /Spider-.vsix + */ +"use strict"; + +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const root = path.resolve(__dirname, ".."); +const stage = path.join(root, ".vsix-stage"); + +const NEEDS_SHELL = process.platform === "win32"; // npm/tar are .cmd/exe shims on Windows + +function fail(message) { + console.error(`[stage-vsix] ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const { capture = false, cwd = root } = options; + const result = spawnSync(command, args, { + cwd, + stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit", + shell: options.shell ?? NEEDS_SHELL, + encoding: "utf8", + }); + if (result.status !== 0) { + const tail = capture ? `\n${(result.stderr || result.stdout || "").trim()}` : ""; + fail(`command failed (${command} ${args.join(" ")}): exit code ${result.status}${tail}`); + } + return result; +} + +function copyIfExists(name, destinationDir = stage) { + const from = path.join(root, name); + if (fs.existsSync(from)) { + fs.cpSync(from, path.join(destinationDir, name), { recursive: true }); + return true; + } + return false; +} + +function stagePackagePayload(manifest) { + for (const entry of ["dist", "assets"]) { + if (!copyIfExists(entry)) fail(`missing required "${entry}/" — run the compile step first`); + } + for (const doc of ["README.md", "LICENSE", "CHANGELOG.md", "SECURITY.md", "SUPPORT.md"]) { + copyIfExists(doc); + } + // Scripts and devDependencies are build-time concerns; the stage has no + // toolchain, and stripping scripts stops vsce from re-running + // vscode:prepublish inside the stage. + delete manifest.scripts; + delete manifest.devDependencies; + fs.writeFileSync(path.join(stage, "package.json"), `${JSON.stringify(manifest, null, 2)}\n`); +} + +function installProductionClosure() { + console.log("[stage-vsix] Installing production dependency closure with npm..."); + run("npm", [ + "install", + "--omit=dev", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--loglevel=error", + ], { cwd: stage }); +} + +function npmPackTarball(name, version, destination) { + const result = run("npm", ["pack", `${name}@${version}`, "--pack-destination", destination, "--loglevel=error"], { + capture: true, + }); + const output = `${result.stdout}\n${result.stderr}`; + const tarballName = output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.endsWith(".tgz")) + .pop(); + if (!tarballName) fail(`could not determine tarball filename for ${name}@${version}. npm said:\n${output}`); + return path.join(destination, path.basename(tarballName)); +} + +function extractTarball(tarball, intoDir) { + fs.mkdirSync(intoDir, { recursive: true }); + // Relative paths only: GNU tar treats "C:\..." as a remote host ("C:"). + run("tar", ["-xzf", path.basename(tarball), "-C", "."], { cwd: intoDir }); +} + +/** + * npm installs only the optionalDependency matching the host os/cpu; a + * Marketplace VSIX must carry every platform so the SDK finds its natives + * (rg, sandbox helper, tree-sitter) at runtime on Windows/macOS/Linux. + */ +function stagePlatformPackages(manifest) { + const sdkPackageJson = path.join(stage, "node_modules", "@cursor", "sdk", "package.json"); + if (!fs.existsSync(sdkPackageJson)) fail("npm install did not produce node_modules/@cursor/sdk"); + const sdkManifest = JSON.parse(fs.readFileSync(sdkPackageJson, "utf8")); + const declaredSdkVersion = manifest.dependencies && manifest.dependencies["@cursor/sdk"]; + if (sdkManifest.version !== declaredSdkVersion) { + fail(`staged @cursor/sdk@${sdkManifest.version} does not match manifest dependency ${declaredSdkVersion}`); + } + + const platformPackages = Object.entries(sdkManifest.optionalDependencies ?? {}).filter(([name]) => + name.startsWith("@cursor/sdk-"), + ); + if (platformPackages.length === 0) { + console.log("[stage-vsix] SDK declares no platform packages; skipping native staging."); + return; + } + + const scratch = path.join(stage, ".tmp-platform-extract"); + fs.mkdirSync(scratch, { recursive: true }); + try { + for (const [name, version] of platformPackages) { + const destination = path.join(stage, "node_modules", ...name.split("/")); + if (fs.existsSync(destination)) { + console.log(`[stage-vsix] ${name}@${version} already installed by npm.`); + continue; + } + console.log(`[stage-vsix] Fetching ${name}@${version} (not installable on this host)...`); + const tarball = npmPackTarball(name, version, scratch); + extractTarball(tarball, scratch); + // npm tarballs extract to a single "package/" directory. + fs.renameSync(path.join(scratch, "package"), destination); + } + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } +} + +function writeStageVscodeIgnore() { + // Everything staged is intentional; trim only what is provably unused at + // runtime (verified against each package's "require" export condition): + // - declaration files are never required at runtime; + // - zod resolves "require" to its root index.cjs (src/ is @zod/source only); + // - @bufbuild/protobuf and @connectrpc/* resolve "require" to dist/cjs; + // - @cursor/sdk resolves "require" to dist/cjs (esm/bundled are bun/import); + // - @statsig/* resolve "require" to ./src/index.js and are KEPT intact. + const ignore = [ + "# Generated by scripts/stage-vsix.js — payload staged for VSIX packaging.", + "**/*.map", + "**/*.d.ts", + "**/*.d.mts", + "**/*.d.cts", + "package-lock.json", + "node_modules/.package-lock.json", + "node_modules/**/.package-lock.json", + "node_modules/.bin/**", + "node_modules/zod/src/**", + "node_modules/@bufbuild/protobuf/dist/esm/**", + "node_modules/@connectrpc/connect/dist/esm/**", + "node_modules/@connectrpc/connect-web/dist/esm/**", + "node_modules/@cursor/sdk/dist/esm/**", + "node_modules/@cursor/sdk/dist/bundled/**", + "", + ]; + fs.writeFileSync(path.join(stage, ".vscodeignore"), ignore.join("\n")); +} + +function runVsce() { + const vsceBin = path.join(root, "node_modules", "@vscode", "vsce", "vsce"); + if (!fs.existsSync(vsceBin)) fail("@vscode/vsce is not installed — run: pnpm install"); + const vsceRealPath = fs.realpathSync(vsceBin); + console.log("[stage-vsix] Packaging VSIX with vsce (from inside the stage)..."); + run(process.execPath, [vsceRealPath, "package", "--out", root], { cwd: stage, shell: false }); +} + +function report() { + const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + const vsixPath = path.join(root, `${manifest.name}-${manifest.version}.vsix`); + if (!fs.existsSync(vsixPath)) fail(`expected VSIX not found: ${vsixPath}`); + const sizeMb = (fs.statSync(vsixPath).size / (1024 * 1024)).toFixed(1); + // Guard against a stale/broken artifact: a root-level `vsce package` run + // writes the same filename but can never include node_modules (vsce's root + // glob ignores it), which is exactly the production failure this script + // exists to prevent. Fail loudly instead of shipping a dead VSIX. + const vsixBytes = fs.readFileSync(vsixPath).toString("latin1"); + for (const entry of [ + "extension/node_modules/@cursor/sdk/package.json", + "extension/node_modules/@cursor/sdk/dist/cjs/index.js", + "extension/dist/extension.js", + ]) { + if (!vsixBytes.includes(entry)) fail(`packaged VSIX is missing ${entry} — do not package with bare vsce; use: pnpm run package`); + } + const packages = fs + .readdirSync(path.join(stage, "node_modules"), { withFileTypes: true }) + .flatMap((entry) => { + if (!entry.isDirectory()) return []; + if (entry.name.startsWith("@")) { + return fs + .readdirSync(path.join(stage, "node_modules", entry.name), { withFileTypes: true }) + .map((child) => `${entry.name}/${child.name}`); + } + return [entry.name]; + }); + console.log(`[stage-vsix] Created ${path.relative(root, vsixPath)} (${sizeMb} MB)`); + console.log(`[stage-vsix] Staged runtime packages (${packages.length}): ${packages.sort().join(", ")}`); +} + +function main() { + if (!fs.existsSync(path.join(root, "dist", "extension.js"))) { + fail("dist/extension.js not found — run the compile step first (pnpm run compile)."); + } + fs.rmSync(stage, { recursive: true, force: true }); + fs.mkdirSync(stage, { recursive: true }); + + const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + stagePackagePayload(manifest); + installProductionClosure(); + stagePlatformPackages(manifest); + writeStageVscodeIgnore(); + runVsce(); + report(); +} + +main(); diff --git a/scripts/vsixSmoke/extensionTests.js b/scripts/vsixSmoke/extensionTests.js new file mode 100644 index 0000000..289906f --- /dev/null +++ b/scripts/vsixSmoke/extensionTests.js @@ -0,0 +1,60 @@ +"use strict"; +/** + * In-VS Code smoke assertions for the INSTALLED Spider VSIX. + * Loaded by @vscode/test-electron via scripts/vsixSmokeTest.mjs. + * + * These run inside a real VS Code instance whose --extensions-dir contains the + * extracted production VSIX (not the development checkout), so they exercise + * exactly what a Marketplace user gets. + */ +const vscode = require("vscode"); + +const COMMAND_IDS = ["spider.openAgent", "spider.openSettings", "spider.openHistory"]; +const CONTAINER_FOCUS_COMMAND = "workbench.view.extension.spider"; +const SPIDER_EXTENSION_IDS = ["ahmadpasha1221.spider", "Ahmadpasha1221.Spider"]; + +async function findSpiderExtension() { + for (const id of SPIDER_EXTENSION_IDS) { + const extension = vscode.extensions.getExtension(id); + if (extension) { + return extension; + } + } + return undefined; +} + +async function run() { + const spider = await findSpiderExtension(); + if (!spider) { + throw new Error( + `Spider VSIX is not installed in the test instance (looked for: ${SPIDER_EXTENSION_IDS.join(", ")})`, + ); + } + + // Exercise the real user flow first: running the command must trigger + // activation (onCommand activation event) and its handler must execute. + // The handler focuses the view container via + // workbench.view.extension. — a wrong id would reject here. + await vscode.commands.executeCommand("spider.openAgent"); + if (!spider.isActive) { + throw new Error("Spider did not activate after executing spider.openAgent"); + } + await vscode.commands.executeCommand("spider.openSettings"); + // Opens the History editor tab: proves dist/gui/history.html + history.js are + // actually packaged (createOrShow reads them synchronously and would throw). + await vscode.commands.executeCommand("spider.openHistory"); + + // Now that Spider is active, its commands must be registered. + const commands = await vscode.commands.getCommands(true); + const missing = COMMAND_IDS.filter((id) => !commands.includes(id)); + if (missing.length > 0) { + throw new Error(`Spider commands not registered after activation: ${missing.join(", ")}`); + } + if (!commands.includes(CONTAINER_FOCUS_COMMAND)) { + throw new Error(`Container focus command missing: ${CONTAINER_FOCUS_COMMAND}`); + } + + console.log("[vsix-smoke] Spider activated; openAgent/openSettings/openHistory executed without error"); +} + +module.exports = { run }; diff --git a/scripts/vsixSmokeTest.mjs b/scripts/vsixSmokeTest.mjs new file mode 100644 index 0000000..8e164a6 --- /dev/null +++ b/scripts/vsixSmokeTest.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * Automated smoke test for the INSTALLED production VSIX. + * + * Unlike `pnpm run test` (unit tests) and F5 (development host), this proves + * the shipped artifact: it installs the VSIX into an isolated VS Code profile + * (--user-data-dir + --extensions-dir), launches a real VS Code instance via + * @vscode/test-electron, and executes in-window assertions: + * + * - Spider commands are registered (spider.openAgent/openSettings/openHistory) + * - workbench.view.extension.spider exists (container focus target) + * - running "Spider: Open Agent" activates the extension without error + * - "Spider: Open History" opens the History editor tab (packaged assets) + * - the Agent webview resolves (see "Agent webview resolved" in exthost log) + * + * Usage: node scripts/vsixSmokeTest.mjs [path/to/Spider-.vsix] + * Exits non-zero on any failure. + */ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const require = createRequire(path.join(root, "package.json")); + +function fail(message) { + console.error(`[vsix-smoke] ${message}`); + process.exit(1); +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit", + shell: process.platform === "win32", + encoding: "utf8", + ...(options.spawn ?? {}), + }); + if (options.capture) { + return { status: result.status, output: `${result.stdout ?? ""}\n${result.stderr ?? ""}` }; + } + if (result.status !== 0) { + fail(`command failed (${command} ${args.join(" ")}): exit ${result.status}`); + } + return { status: result.status, output: "" }; +} + +function extractVsix(vsixPath, extensionsDir, expectedDirName) { + const scratch = path.join(path.dirname(vsixPath), ".vsix-smoke-extract"); + fs.rmSync(scratch, { recursive: true, force: true }); + fs.mkdirSync(scratch, { recursive: true }); + if (process.platform === "win32") { + const zipCopy = path.join(scratch, "vsix.zip"); + fs.copyFileSync(vsixPath, zipCopy); + run("powershell.exe", [ + "-NoProfile", + "-Command", + `Expand-Archive -LiteralPath '${zipCopy}' -DestinationPath '${scratch}/unzip' -Force`, + ]); + fs.renameSync(path.join(scratch, "unzip", "extension"), path.join(extensionsDir, expectedDirName)); + } else { + run("unzip", ["-q", vsixPath, "-d", scratch]); + fs.renameSync(path.join(scratch, "extension"), path.join(extensionsDir, expectedDirName)); + } + fs.rmSync(scratch, { recursive: true, force: true }); +} + +async function main() { + const manifest = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); + const vsixArg = process.argv[2]; + const vsixPath = path.resolve(vsixArg ?? path.join(root, `${manifest.name}-${manifest.version}.vsix`)); + if (!fs.existsSync(vsixPath)) { + fail(`VSIX not found: ${vsixPath} — build it first with: pnpm run package`); + } + + const expectedDirName = `${manifest.publisher}.${manifest.name}-${manifest.version}`.toLowerCase(); + const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "vsix-smoke-ws-")); + const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), "vsix-smoke-profile-")); + const extensionsDir = fs.mkdtempSync(path.join(os.tmpdir(), "vsix-smoke-ext-")); + const devExtDir = fs.mkdtempSync(path.join(os.tmpdir(), "vsix-smoke-dev-")); + const scratch = path.join(path.dirname(vsixPath), ".vsix-smoke-extract"); + + // runTests requires an extensionDevelopmentPath; a declaration-only manifest + // is a valid but inert development extension so the harness loads nothing + // from the checkout — only the installed VSIX is under test. + fs.writeFileSync( + path.join(devExtDir, "package.json"), + JSON.stringify({ + name: "vsix-smoke-harness", + publisher: "local", + version: "0.0.0", + engines: { vscode: manifest.engines.vscode }, + }), + ); + + console.log(`[vsix-smoke] Installing ${path.basename(vsixPath)} into isolated profile...`); + extractVsix(vsixPath, extensionsDir, expectedDirName); + if (!fs.existsSync(path.join(extensionsDir, expectedDirName, "node_modules", "@cursor", "sdk"))) { + fail("installed VSIX does not contain node_modules/@cursor/sdk — packaging is broken"); + } + + const { runTests } = require("@vscode/test-electron"); + try { + await runTests({ + extensionDevelopmentPath: devExtDir, + extensionTestsPath: path.join(root, "scripts", "vsixSmoke", "extensionTests.js"), + launchArgs: [ + workspace, + "--user-data-dir", + userDataDir, + "--extensions-dir", + extensionsDir, + "--disable-gpu", + "--disable-workspace-trust", + ], + }); + } catch (error) { + fail(`in-window smoke test FAILED: ${error?.message ?? error}`); + } finally { + fs.rmSync(scratch, { recursive: true, force: true }); + } + + // Grep the isolated instance's extension-host logs for the failures this + // task exists to prevent, and for the markers that prove the happy path. + let logRoot = null; + for (const candidate of [path.join(userDataDir, "logs"), path.join(userDataDir, "data", "logs")]) { + if (fs.existsSync(candidate)) { + logRoot = candidate; + break; + } + } + if (!logRoot) { + console.log("[vsix-smoke] NOTE: no logs directory found under the isolated profile; skipped log audit"); + return; + } + const collected = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(p); + } else if (entry.isFile() && entry.name.endsWith(".log")) { + collected.push(p); + } + } + }; + walk(logRoot); + const contents = collected.map((p) => ({ p, text: fs.readFileSync(p, "utf8") })); + const badPatterns = [ + /Cannot find module '@cursor\/sdk'/, + /MODULE_NOT_FOUND/, + /command 'spider\.openAgent' not found/, + /command 'spider\.openHistory' not found/, + /does not register a chat participant/, + /Activating extension .*Spider.* failed/, + ]; + const failures = []; + for (const pattern of badPatterns) { + for (const { p, text } of contents) { + if (pattern.test(text)) { + failures.push(`${path.relative(userDataDir, p)}: ${pattern}`); + } + } + } + if (failures.length > 0) { + fail(`log audit found fatal patterns:\n${failures.join("\n")}`); + } + const webviewResolved = contents.some(({ text }) => text.includes("Agent webview resolved")); + console.log( + webviewResolved + ? "[vsix-smoke] PASS: activation, command registration, container focus and Agent webview resolve all verified from the installed VSIX" + : "[vsix-smoke] PASS (partial): activation + commands verified; 'Agent webview resolved' marker not found in logs", + ); +} + +main().catch((error) => fail(error?.stack ?? String(error))); diff --git a/src/auth/cursorAuthProvider.ts b/src/auth/cursorAuthProvider.ts index aa4c74e..032f442 100644 --- a/src/auth/cursorAuthProvider.ts +++ b/src/auth/cursorAuthProvider.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { SecretStorage } from "./secretStorage"; import { CursorAuthError } from "./cursorAuthError"; +import { SECRET_KEYS, deleteSecret, readSecret } from "../shared/storageKeys"; export class CursorAuthProvider implements vscode.AuthenticationProvider { private readonly emitter = new vscode.EventEmitter(); @@ -19,7 +20,7 @@ export class CursorAuthProvider implements vscode.AuthenticationProvider { scopes: readonly string[], _options: vscode.AuthenticationProviderSessionOptions, ): Promise { - const token = await this.secretStorage.get("codeviaCursor.token"); + const token = await readSecret(this.secretStorage, SECRET_KEYS.cursorApiKey); if (!token) { throw new CursorAuthError("No Cursor API key found", 401); @@ -33,7 +34,7 @@ export class CursorAuthProvider implements vscode.AuthenticationProvider { }; this.sessions.push(session); - await this.secretStorage.store("codeviaCursor.session", JSON.stringify(session)); + await this.secretStorage.store(SECRET_KEYS.cursorSession.current, JSON.stringify(session)); this.emitter.fire({ added: [session], removed: undefined, changed: undefined }); return session; } @@ -49,7 +50,7 @@ export class CursorAuthProvider implements vscode.AuthenticationProvider { } const [removed] = this.sessions.splice(index, 1); - await this.secretStorage.delete("codeviaCursor.session"); + await deleteSecret(this.secretStorage, SECRET_KEYS.cursorSession); this.emitter.fire({ added: undefined, removed: [removed], changed: undefined }); } diff --git a/src/auth/cursorClient.ts b/src/auth/cursorClient.ts index 89686e7..15ae1b9 100644 --- a/src/auth/cursorClient.ts +++ b/src/auth/cursorClient.ts @@ -16,10 +16,24 @@ export interface CreateAgentOptions { export class CursorClient { private apiKey: string | undefined; + private sdkConfigured = false; constructor(apiKey: string | undefined) { this.apiKey = apiKey; + } + + /** + * configureCursorSdk is deferred until the first SDK call. Extension + * activation must never depend on the Cursor runtime: commands and views + * register before any Cursor state is touched, and a missing/broken Cursor + * environment only degrades the Cursor provider, not the whole extension. + */ + private ensureSdkConfigured(): void { + if (this.sdkConfigured) { + return; + } configureCursorSdk({}); + this.sdkConfigured = true; } hasApiKey(): boolean { @@ -73,6 +87,7 @@ export class CursorClient { nameOrOptions: string | CreateAgentOptions, workspacePath?: string, ): Promise { + this.ensureSdkConfigured(); const options = typeof nameOrOptions === "string" ? { name: nameOrOptions, workspacePath: workspacePath ?? "." } : nameOrOptions; @@ -95,6 +110,7 @@ export class CursorClient { } async getAgent(agentId: string): Promise { + this.ensureSdkConfigured(); try { return await Agent.get(agentId, { apiKey: this.apiKey }); } catch (error) { @@ -103,6 +119,7 @@ export class CursorClient { } async listAgents(): Promise { + this.ensureSdkConfigured(); try { const result = await Agent.list(); return result.items; @@ -112,6 +129,7 @@ export class CursorClient { } async resumeAgent(agentId: string): Promise { + this.ensureSdkConfigured(); try { return await Agent.resume(agentId, { apiKey: this.apiKey }); } catch (error) { diff --git a/src/auth/cursorConnection.ts b/src/auth/cursorConnection.ts index e58372a..240a183 100644 --- a/src/auth/cursorConnection.ts +++ b/src/auth/cursorConnection.ts @@ -1,7 +1,7 @@ import { CursorClient } from "./cursorClient"; import { CursorAuthError } from "./cursorAuthError"; import { SecretStorage } from "./secretStorage"; -import { API_KEY_SECRET_KEY } from "../shared/constants"; +import { SECRET_KEYS, deleteSecret, readSecret } from "../shared/storageKeys"; import type { AuthStatusMessage } from "../webview/types"; export interface CursorConnection { @@ -19,7 +19,7 @@ export class CursorConnectionService implements CursorConnection { ) {} async restore(): Promise { - const stored = await this.secretStorage.get(API_KEY_SECRET_KEY); + const stored = await readSecret(this.secretStorage, SECRET_KEYS.cursorApiKey); if (!stored) { this.cursorClient.setApiKey(undefined); return this.statusMessage("disconnected", false); @@ -31,7 +31,7 @@ export class CursorConnectionService implements CursorConnection { async connect(apiKey?: string): Promise { const incoming = apiKey?.trim(); - const stored = await this.secretStorage.get(API_KEY_SECRET_KEY); + const stored = await readSecret(this.secretStorage, SECRET_KEYS.cursorApiKey); const key = incoming && incoming.length > 0 ? incoming : stored; if (!key) { @@ -44,13 +44,13 @@ export class CursorConnectionService implements CursorConnection { try { const message = await this.cursorClient.validateConnection(this.getWorkspacePath()); if (incoming && incoming.length > 0) { - await this.secretStorage.store(API_KEY_SECRET_KEY, incoming); + await this.secretStorage.store(SECRET_KEYS.cursorApiKey.current, incoming); } return this.statusMessage("connected", true, undefined, message); } catch (error) { this.cursorClient.setApiKey(undefined); if (incoming && incoming.length > 0) { - await this.secretStorage.delete(API_KEY_SECRET_KEY); + await deleteSecret(this.secretStorage, SECRET_KEYS.cursorApiKey); } return this.statusMessage("error", Boolean(stored) && !incoming, this.toUserError(error)); } @@ -58,12 +58,12 @@ export class CursorConnectionService implements CursorConnection { async disconnect(): Promise { this.cursorClient.setApiKey(undefined); - await this.secretStorage.delete(API_KEY_SECRET_KEY); + await deleteSecret(this.secretStorage, SECRET_KEYS.cursorApiKey); return this.statusMessage("disconnected", false); } async getStatus(): Promise { - const hasKey = Boolean(await this.secretStorage.get(API_KEY_SECRET_KEY)); + const hasKey = (await readSecret(this.secretStorage, SECRET_KEYS.cursorApiKey)) !== undefined; if (this.cursorClient.hasApiKey()) { return this.statusMessage("connected", hasKey); } diff --git a/src/auth/providerCredentials.ts b/src/auth/providerCredentials.ts index a33dcda..f009d3f 100644 --- a/src/auth/providerCredentials.ts +++ b/src/auth/providerCredentials.ts @@ -1,4 +1,5 @@ import type { SecretStorage } from "./secretStorage"; +import { deleteSecret, providerSecretKeyPair, readSecret } from "../shared/storageKeys"; /** * Generic credential layer for provider profiles. @@ -8,7 +9,7 @@ import type { SecretStorage } from "./secretStorage"; * resolves "the credential for this profile" and nothing else. */ export function providerSecretKey(profileId: string): string { - return `codeviaCursor.provider.${profileId}.apiKey`; + return providerSecretKeyPair(profileId).current; } export interface ProviderCredentialStore { @@ -34,8 +35,10 @@ export function createProviderCredentialStore( return { async get(profileId) { - const value = await secrets.get(providerSecretKey(profileId)); - if (value && value.length > 0) { + // New (`spider.provider.`), then pre-rename + // (`codeviaCursor.provider.`), then any shape-mismatched legacy key. + const value = await readSecret(secrets, providerSecretKeyPair(profileId)); + if (value !== undefined) { return value; } const legacyKey = legacyKeyFor(profileId); @@ -47,9 +50,10 @@ export function createProviderCredentialStore( }, async store(profileId, secret) { - await secrets.store(providerSecretKey(profileId), secret); - // The profile slot now holds the credential: drop the legacy key so the - // secret exists in exactly one place. + await secrets.store(providerSecretKeyPair(profileId).current, secret); + // The profile slot now holds the credential: drop the pre-rename slot and + // any shape-mismatched legacy key so the secret exists in exactly one place. + await secrets.delete(providerSecretKeyPair(profileId).legacy); const legacyKey = legacyKeyFor(profileId); if (legacyKey) { await secrets.delete(legacyKey); @@ -57,7 +61,7 @@ export function createProviderCredentialStore( }, async delete(profileId) { - await secrets.delete(providerSecretKey(profileId)); + await deleteSecret(secrets, providerSecretKeyPair(profileId)); const legacyKey = legacyKeyFor(profileId); if (legacyKey) { await secrets.delete(legacyKey); diff --git a/src/chat/chatParticipant.ts b/src/chat/chatParticipant.ts new file mode 100644 index 0000000..95e67fa --- /dev/null +++ b/src/chat/chatParticipant.ts @@ -0,0 +1,176 @@ +import * as vscode from "vscode"; +import { CHAT_PARTICIPANT_ID, CHAT_PERMISSION_COMMANDS } from "../shared/constants"; +import type { PermissionManager } from "../permissions/permissionManager"; +import type { RuntimeManager } from "../runtime/runtimeManager"; +import type { RuntimeEvent } from "../runtime/runtimeTypes"; + +export interface SpiderChatParticipantOptions { + readonly extensionUri: vscode.Uri; + readonly runtimeManager: RuntimeManager; + readonly permissionManager: PermissionManager; + readonly defaultWorkspacePath: string; +} + +/** + * `/explain`, `/fix`, `/test` (the commands contributed in package.json) map to + * prompt preambles — VS Code replaces `request.prompt` with the text after the + * command, so the intent has to be re-attached here. + */ +const COMMAND_PREAMBLES: Readonly> = { + explain: "Explain the selected code or the referenced context. Be concise.", + fix: "Find and fix the problem in the selected code or the reported issue.", + test: "Write focused tests for the selected code.", +}; + +/** + * VS Code Chat participant for `@spider`. + * + * It is a thin bridge over the same RuntimeManager the sidebar uses: a dedicated + * session keeps chat turns separate from the Agent view's conversation, text + * deltas stream straight into the chat response, and tool activity surfaces as + * progress. Non-destructive permission requests are auto-approved; destructive + * ones surface Allow/Deny buttons so the user decides in chat instead of the + * request being silently denied. + */ +export function registerSpiderChatParticipant( + options: SpiderChatParticipantOptions, +): vscode.Disposable { + const { runtimeManager, permissionManager } = options; + let chatSessionId: string | undefined; + + const ensureSession = () => { + if (chatSessionId) { + const existing = runtimeManager.getSession(chatSessionId); + if (existing) { + return existing; + } + } + const created = runtimeManager.createSession(options.defaultWorkspacePath); + chatSessionId = created.sessionId; + return created; + }; + + const handler: vscode.ChatRequestHandler = async (request, _context, stream, token) => { + const prompt = buildPrompt(request); + if (prompt.length === 0) { + stream.markdown("Ask Spider something about your workspace."); + return; + } + + if (!runtimeManager.provider) { + stream.markdown( + "**Spider has no AI provider configured.** Open the Spider sidebar and choose " + + "Local AI (Ollama), OpenRouter, or Cursor in Settings, then try again.", + ); + return; + } + + const session = ensureSession(); + let streamed = false; + let errorReported = false; + + const subscription = runtimeManager.onDidPublishEvent((event: RuntimeEvent) => { + if (event.sessionId !== session.sessionId) { + return; + } + switch (event.type) { + case "text_delta": + if (event.text.length > 0) { + streamed = true; + stream.markdown(event.text); + } + break; + case "assistant_message": + // Deltas already painted this reply; only fall back to the final + // message when the provider did not stream. + if (!streamed && event.message.length > 0) { + stream.markdown(event.message); + } + break; + case "thinking": + stream.progress(event.message); + break; + case "tool_call": + case "tool_running": + stream.progress(`Running ${event.toolCall.name}…`); + break; + case "permission_request": + if (event.request.destructive) { + // Destructive tools must not be auto-approved: surface the request + // as Allow/Deny buttons and let the user decide. The run stays + // suspended until a decision (or the policy's timeout) resolves it. + stream.markdown( + `\n\n**Permission required** — ${event.request.description}\n\n`, + ); + stream.button({ + command: CHAT_PERMISSION_COMMANDS.allow, + title: "Allow once", + arguments: [event.request.requestId, event.request.description], + }); + stream.button({ + command: CHAT_PERMISSION_COMMANDS.deny, + title: "Deny", + arguments: [event.request.requestId, event.request.description], + }); + } else { + // Non-destructive work stays frictionless (same policy the sidebar + // applies for reads/executes). + permissionManager.resolveDecision({ + requestId: event.request.requestId, + decision: "ALLOW", + confirmation: true, + }); + } + break; + case "user_question": { + const answer = + event.request.defaultOption + ?? event.request.options?.[0]?.value + ?? ""; + stream.progress(event.request.question); + if (answer.length > 0) { + runtimeManager.resolveUserQuestion(event.request.requestId, answer); + } else { + runtimeManager.cancelUserQuestion(event.request.requestId); + } + break; + } + case "error": + errorReported = true; + stream.markdown(`\n\n**Spider error:** ${event.error.message}`); + break; + default: + break; + } + }); + + const cancellation = token.onCancellationRequested(() => { + void runtimeManager.cancelTask(session.sessionId); + }); + + try { + await runtimeManager.startTask(session.sessionId, prompt, token); + } catch (error) { + if (!errorReported) { + stream.markdown(`\n\n**Spider could not complete the request:** ${errorMessage(error)}`); + } + } finally { + cancellation.dispose(); + subscription.dispose(); + } + }; + + const participant = vscode.chat.createChatParticipant(CHAT_PARTICIPANT_ID, handler); + participant.iconPath = vscode.Uri.joinPath(options.extensionUri, "assets", "icon.png"); + return participant; +} + +function buildPrompt(request: vscode.ChatRequest): string { + const prompt = request.prompt.trim(); + const preamble = request.command ? COMMAND_PREAMBLES[request.command] : undefined; + return preamble ? `${preamble}\n\n${prompt}` : prompt; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/extension.ts b/src/extension.ts index 40aac03..c8ecd7b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,5 +1,7 @@ import * as vscode from "vscode"; -import { COMMANDS, EXTENSION_NAME, OPENROUTER_API_KEY_SECRET_KEY, WEB_SEARCH_API_KEY_SECRET_KEY } from "./shared/constants"; +import { CHAT_PERMISSION_COMMANDS, COMMANDS, EXTENSION_NAME } from "./shared/constants"; +import { SECRET_KEYS, STORAGE_KEYS, readState } from "./shared/storageKeys"; +import { registerSpiderChatParticipant } from "./chat/chatParticipant"; import { Logger } from "./utils/logger"; import { CursorAuthProvider } from "./auth/cursorAuthProvider"; import { VSCodeSecretStorageAdapter } from "./auth/secretStorage"; @@ -11,7 +13,9 @@ import { SessionStore } from "./session/sessionStore"; import { ProviderConfigStore, profileIdFor } from "./session/providerConfigStore"; import { TranscriptStore } from "./session/transcriptStore"; import { MessageRouter } from "./webview/messageRouter"; +import { AgentWebviewHost } from "./webview/agentWebviewHost"; import { AgentViewProvider } from "./webview/agentViewProvider"; +import { AgentEditorPanel } from "./webview/agentEditorPanel"; import { PermissionManager } from "./permissions/permissionManager"; import { createDefaultPermissionPolicy } from "./permissions/permissionPolicy"; import { RuntimeManager } from "./runtime/runtimeManager"; @@ -40,7 +44,7 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push( vscode.authentication.registerAuthenticationProvider( - "codeviaCursor", + "spider", "Spider", authProvider, { supportsMultipleAccounts: false }, @@ -53,12 +57,12 @@ export async function activate(context: vscode.ExtensionContext): Promise }), { load: () => { - const raw = context.workspaceState.get("codeviaCursor.permissionRules"); + const raw = readState(context.workspaceState, STORAGE_KEYS.permissionRules); return Array.isArray(raw) ? (raw as ReadonlyArray<{ category: string; rule: string }>) : []; }, save: (snapshot) => { void context.workspaceState.update( - "codeviaCursor.permissionRules", + STORAGE_KEYS.permissionRules.current, Object.entries(snapshot.rules).map(([category, rule]) => ({ category, rule })), ); }, @@ -70,7 +74,7 @@ export async function activate(context: vscode.ExtensionContext): Promise // The legacy single OpenRouter key is the migration source for the default // OpenRouter profile. const providerCredentials = createProviderCredentialStore(secretStorage, { - legacySecretKeys: { [profileIdFor("openrouter")]: OPENROUTER_API_KEY_SECRET_KEY }, + legacySecretKeys: { [profileIdFor("openrouter")]: SECRET_KEYS.openRouterApiKey.legacy }, }); const sessionStore = new SessionStore(context.workspaceState); @@ -85,7 +89,8 @@ export async function activate(context: vscode.ExtensionContext): Promise // no key is read (or held) until the tool is actually used. const webSearch = createStoredWebSearchProvider({ secretStorage, - secretKey: WEB_SEARCH_API_KEY_SECRET_KEY, + secretKey: SECRET_KEYS.webSearchApiKey.current, + legacySecretKey: SECRET_KEYS.webSearchApiKey.legacy, }); const toolExecutor = new WorkspaceToolExecutor({ diagnostics: createVSCodeDiagnosticsSource(), @@ -115,7 +120,9 @@ export async function activate(context: vscode.ExtensionContext): Promise secretStorage, { permissionManager }, ); - const agentViewProvider = new AgentViewProvider( + // One host bridges the runtime to every Spider surface: the + // sidebar Agent view and the editor-area Spider tab. + const agentWebviewHost = new AgentWebviewHost( context.extensionUri, agentManager, messageRouter, @@ -123,13 +130,91 @@ export async function activate(context: vscode.ExtensionContext): Promise runtimeManager, permissionManager, ); + const agentViewProvider = new AgentViewProvider(agentWebviewHost); + // --- Register everything VS Code can invoke BEFORE any async work. If a + // provider fails to initialize later, commands and the Agent view must + // still exist (a failed activation is what surfaced as + // "command 'spider.openAgent' not found" in packaged installs). context.subscriptions.push( - vscode.window.registerWebviewViewProvider("codeviaCursor.agent", agentViewProvider, { + vscode.window.registerWebviewViewProvider("spider.agent", agentViewProvider, { webviewOptions: { retainContextWhenHidden: true }, }), ); + // Spider's primary surface is an editor-area tab (the Codex / + // Cline / Kilo style editor view). The sidebar stays available + // from the activity bar; both show the same conversation. + const openAgentCommand = vscode.commands.registerCommand(COMMANDS.openAgent, async () => { + logger.info("Open agent command invoked", { operation: "openAgent" }); + AgentEditorPanel.createOrShow(context.extensionUri, agentWebviewHost); + }); + + // Explicit editor-tab command (palette + clickable Spider logo). + const openAgentEditorCommand = vscode.commands.registerCommand(COMMANDS.openAgentEditor, async () => { + logger.info("Open agent editor command invoked", { operation: "openAgentEditor" }); + AgentEditorPanel.createOrShow(context.extensionUri, agentWebviewHost); + }); + + const openSettingsCommand = vscode.commands.registerCommand(COMMANDS.openSettings, async () => { + logger.info("Open settings command invoked", { operation: "openSettings" }); + AgentEditorPanel.createOrShow(context.extensionUri, agentWebviewHost); + agentWebviewHost.showSettings(); + }); + + const openHistoryCommand = vscode.commands.registerCommand(COMMANDS.openHistory, async () => { + logger.info("Open history command invoked", { operation: "openHistory" }); + agentWebviewHost.openHistory(); + }); + + context.subscriptions.push( + openAgentCommand, + openAgentEditorCommand, + openSettingsCommand, + openHistoryCommand, + ); + + // Clickable Spider logo: one click opens the Spider editor tab. + const spiderStatusItem = vscode.window.createStatusBarItem( + "spider.agent", + vscode.StatusBarAlignment.Left, + 50, + ); + spiderStatusItem.name = "Spider Agent"; + spiderStatusItem.text = "🕷️ Spider"; + spiderStatusItem.tooltip = "Open the Spider agent in an editor tab"; + spiderStatusItem.command = COMMANDS.openAgentEditor; + spiderStatusItem.show(); + context.subscriptions.push(spiderStatusItem); + + // VS Code Chat (@spider): same RuntimeManager as the sidebar, dedicated + // session per chat lifetime. Registered at activation so + // onChatParticipant:spider.spider can resolve it. + context.subscriptions.push( + registerSpiderChatParticipant({ + extensionUri: context.extensionUri, + runtimeManager, + permissionManager, + defaultWorkspacePath: getWorkspacePath(), + }), + ); + + // Allow/Deny buttons the chat participant renders for destructive tools. + const resolveChatPermission = (decision: "ALLOW" | "DENY") => (requestId: unknown) => { + if (typeof requestId !== "string" || requestId.length === 0) { + return; + } + permissionManager.resolveDecision({ + requestId, + decision, + confirmation: decision === "ALLOW", + }); + }; + context.subscriptions.push( + vscode.commands.registerCommand(CHAT_PERMISSION_COMMANDS.allow, resolveChatPermission("ALLOW")), + vscode.commands.registerCommand(CHAT_PERMISSION_COMMANDS.deny, resolveChatPermission("DENY")), + ); + // Shield toggles flow through the same event bridge as runtime events so // every webview re-syncs to the authoritative backend state. context.subscriptions.push( @@ -142,40 +227,37 @@ export async function activate(context: vscode.ExtensionContext): Promise context.subscriptions.push(permissionManager, agentManager, runtimeManager, diffView, agentViewProvider); - await agentManager.restoreSessions(); - await runtimeManager.restoreSessions(); - - // Hydrate the previously selected provider profile so the user is not asked - // to configure the provider again. Non-secret configuration is restored from - // the profile store; the credential is resolved from SecretStorage by profile - // id — the host owns credentials, the webview only ever sees sanitized state. - const restoredProvider = await runtimeManager.restoreProviderConfig(); - if (restoredProvider && !restoredProvider.applied) { - const { config } = restoredProvider; - const credential = await providerCredentials.get(config.profileId ?? profileIdFor(config.provider)); - if (credential) { - await runtimeManager.completeRestore(config, credential); - if (runtimeManager.listSessions().length === 0) { - runtimeManager.createSession(getWorkspacePath()); + // --- Optional heavyweight restoration runs after registration and must + // never break activation: a provider that cannot initialize only degrades + // that provider — the UI and all commands stay available. + try { + await agentManager.restoreSessions(); + await runtimeManager.restoreSessions(); + + // Hydrate the previously selected provider profile so the user is not asked + // to configure the provider again. Non-secret configuration is restored from + // the profile store; the credential is resolved from SecretStorage by profile + // id — the host owns credentials, the webview only ever sees sanitized state. + const restoredProvider = await runtimeManager.restoreProviderConfig(); + if (restoredProvider && !restoredProvider.applied) { + const { config } = restoredProvider; + const credential = await providerCredentials.get(config.profileId ?? profileIdFor(config.provider)); + if (credential) { + await runtimeManager.completeRestore(config, credential); + if (runtimeManager.listSessions().length === 0) { + runtimeManager.createSession(getWorkspacePath()); + } + } else { + logger.info("Saved provider profile has no stored credential; skipping restore", { operation: "activate" }); } - } else { - logger.info("Saved provider profile has no stored credential; skipping restore", { operation: "activate" }); } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + logger.error(`Optional startup restoration failed; Spider UI remains available: ${detail}`, { + operation: "activate", + }); } - const openAgentCommand = vscode.commands.registerCommand(COMMANDS.openAgent, async () => { - logger.info("Open agent command invoked", { operation: "openAgent" }); - await vscode.commands.executeCommand("workbench.view.extension.codeviaCursor.agent"); - }); - - const openSettingsCommand = vscode.commands.registerCommand(COMMANDS.openSettings, async () => { - logger.info("Open settings command invoked", { operation: "openSettings" }); - await vscode.commands.executeCommand("workbench.view.extension.codeviaCursor.agent"); - agentViewProvider.showSettings(); - }); - - context.subscriptions.push(openAgentCommand, openSettingsCommand); - logger.info("Extension activated", { operation: "activate" }); } diff --git a/src/runtime/net/webSearchProvider.ts b/src/runtime/net/webSearchProvider.ts index 6b2eb31..2e2c257 100644 --- a/src/runtime/net/webSearchProvider.ts +++ b/src/runtime/net/webSearchProvider.ts @@ -1,4 +1,5 @@ import type { SecretStorage } from "../../auth/secretStorage"; +import { readSecret } from "../../shared/storageKeys"; /** * Web-search provider abstraction. @@ -62,6 +63,8 @@ export interface StoredWebSearchProviderOptions { readonly secretStorage: SecretStorage; /** SecretStorage key holding the provider API key. */ readonly secretKey: string; + /** Pre-rename key read as a fallback (see shared/storageKeys.ts). */ + readonly legacySecretKey?: string; /** Injectable for tests and alternative hosts. */ readonly fetchFn?: typeof fetch; readonly endpoint?: string; @@ -81,7 +84,12 @@ export function createStoredWebSearchProvider(options: StoredWebSearchProviderOp return { id: options.id ?? "stored", async search(request) { - const apiKey = await options.secretStorage.get(options.secretKey); + const apiKey = options.legacySecretKey + ? await readSecret(options.secretStorage, { + current: options.secretKey, + legacy: options.legacySecretKey, + }) + : await options.secretStorage.get(options.secretKey); if (!apiKey || apiKey.trim().length === 0) { throw new WebSearchProviderError("not_configured", "No web search API key is configured."); } diff --git a/src/session/providerConfigStore.ts b/src/session/providerConfigStore.ts index ae18816..cb9912d 100644 --- a/src/session/providerConfigStore.ts +++ b/src/session/providerConfigStore.ts @@ -1,13 +1,13 @@ import * as vscode from "vscode"; import type { RuntimeProviderConfig } from "../runtime/runtimeTypes"; +import { STORAGE_KEYS, readState } from "../shared/storageKeys"; /** - * Legacy single-slot key: still read (migration source) and still written (the - * active profile is projected into it) so nothing downstream breaks. + * Single-slot key: still read (migration source, and a fallback for the + * pre-rename `codeviaCursor.providerConfig`) and still written (the active + * profile is projected into it) so nothing downstream breaks. */ -const LEGACY_PROVIDER_CONFIG_STORAGE_KEY = "codeviaCursor.providerConfig"; -/** Profile list. Non-secret data only — credentials live in SecretStorage. */ -const PROVIDER_PROFILES_STORAGE_KEY = "codeviaCursor.providerProfiles"; +const PROVIDER_CONFIG_STORAGE_KEY = STORAGE_KEYS.providerConfig.current; /** * A provider profile is the non-secret half of a connection: which provider, @@ -98,7 +98,7 @@ export class ProviderConfigStore { /** All profiles plus the active one, migrating the legacy record on first read. */ loadProfiles(): ProviderProfileState { - const raw = this.workspaceState.get(PROVIDER_PROFILES_STORAGE_KEY); + const raw = readState(this.workspaceState, STORAGE_KEYS.providerProfiles); if (isRecord(raw) && Array.isArray(raw.profiles)) { const profiles = raw.profiles.map(toProviderProfile).filter((profile): profile is ProviderProfile => profile !== undefined); if (profiles.length > 0) { @@ -158,7 +158,7 @@ export class ProviderConfigStore { /** Persists the profile list and keeps the legacy projection in sync. */ async saveProfiles(state: ProviderProfileState): Promise { - await this.workspaceState.update(PROVIDER_PROFILES_STORAGE_KEY, { + await this.workspaceState.update(STORAGE_KEYS.providerProfiles.current, { profiles: state.profiles.map(toPlainProfile), ...(state.activeProfileId ? { activeProfileId: state.activeProfileId } : {}), }); @@ -167,7 +167,7 @@ export class ProviderConfigStore { if (!active) { return; } - await this.workspaceState.update(LEGACY_PROVIDER_CONFIG_STORAGE_KEY, { + await this.workspaceState.update(PROVIDER_CONFIG_STORAGE_KEY, { provider: active.provider, ...(active.modelId ? { modelId: active.modelId } : {}), ...(active.baseUrl ? { baseUrl: active.baseUrl } : {}), @@ -195,7 +195,7 @@ export class ProviderConfigStore { } private loadLegacy(): PersistedProviderConfig | undefined { - const raw = this.workspaceState.get(LEGACY_PROVIDER_CONFIG_STORAGE_KEY); + const raw = readState(this.workspaceState, STORAGE_KEYS.providerConfig); if (!isRecord(raw) || typeof raw.provider !== "string" || raw.provider.length === 0) { return undefined; } diff --git a/src/session/sessionStore.ts b/src/session/sessionStore.ts index db3adc5..8e77e9b 100644 --- a/src/session/sessionStore.ts +++ b/src/session/sessionStore.ts @@ -1,9 +1,10 @@ import * as vscode from "vscode"; import { RuntimeProvider } from "../runtime/runtimeTypes"; import { AgentErrorInfo, AgentSession, AgentStatus } from "../agent/agentSession"; +import { STORAGE_KEYS, readState } from "../shared/storageKeys"; -const SESSIONS_STORAGE_KEY = "codeviaCursor.sessions"; -const ACTIVE_SESSION_STORAGE_KEY = "codeviaCursor.activeSession"; +const SESSIONS_STORAGE_KEY = STORAGE_KEYS.sessions.current; +const ACTIVE_SESSION_STORAGE_KEY = STORAGE_KEYS.activeSession.current; const AGENT_STATUSES: readonly AgentStatus[] = [ "IDLE", @@ -28,7 +29,7 @@ export class SessionStore { constructor(private readonly workspaceState: vscode.Memento) {} loadSessions(): AgentSession[] { - const raw = this.workspaceState.get(SESSIONS_STORAGE_KEY, []); + const raw = readState(this.workspaceState, STORAGE_KEYS.sessions, []); if (!Array.isArray(raw)) { return []; } @@ -40,7 +41,7 @@ export class SessionStore { } loadActiveSessionId(): string | undefined { - const sessionId = this.workspaceState.get(ACTIVE_SESSION_STORAGE_KEY); + const sessionId = readState(this.workspaceState, STORAGE_KEYS.activeSession); return typeof sessionId === "string" && sessionId.length > 0 ? sessionId : undefined; } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0b17f17..c6dd8cb 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,18 +1,39 @@ -export const EXTENSION_ID = "codevia-cursor"; +import { SECRET_KEYS } from "./storageKeys"; + +export const EXTENSION_ID = "spider"; /** User-facing product name shown in the UI and logs. */ export const EXTENSION_NAME = "Spider"; /** Extension version for Settings → About Spider (kept in sync with package.json). */ export const EXTENSION_VERSION = "0.1.0"; export const COMMANDS = { - openAgent: "codeviaCursor.openAgent", - openSettings: "codeviaCursor.openSettings", + openAgent: "spider.openAgent", + openAgentEditor: "spider.openAgentEditor", + openSettings: "spider.openSettings", + openHistory: "spider.openHistory", +} as const; + +/** + * VS Code Chat participant id. Kept in sync with `contributes.chatParticipants` + * in package.json — a mismatch means the participant never activates. + */ +export const CHAT_PARTICIPANT_ID = "spider.spider"; + +/** + * Commands the chat participant's Allow/Deny buttons invoke, carrying the + * permission requestId. Registered at activation but not contributed to the + * Command Palette — they are meaningless outside a pending chat request. + */ +export const CHAT_PERMISSION_COMMANDS = { + allow: "spider.chatPermission.allow", + deny: "spider.chatPermission.deny", } as const; -export const API_KEY_SECRET_KEY = "codeviaCursor.token"; +/** SecretStorage keys (renamed from `codeviaCursor.*`; see shared/storageKeys.ts). */ +export const API_KEY_SECRET_KEY = SECRET_KEYS.cursorApiKey.current; /** Key in VS Code SecretStorage for the OpenRouter API key (never logged). */ -export const OPENROUTER_API_KEY_SECRET_KEY = "codeviaCursor.openrouter.key"; +export const OPENROUTER_API_KEY_SECRET_KEY = SECRET_KEYS.openRouterApiKey.current; export const BRAND_COLOR = "#7C3AED"; @@ -20,7 +41,7 @@ export const BRAND_COLOR = "#7C3AED"; * SecretStorage key for the web-search provider API key used by search_web. * Read lazily by the provider on every call; never logged or sent to the GUI. */ -export const WEB_SEARCH_API_KEY_SECRET_KEY = "codeviaCursor.webSearch.apiKey"; +export const WEB_SEARCH_API_KEY_SECRET_KEY = SECRET_KEYS.webSearchApiKey.current; export const PERMISSION_DEFAULT_TIMEOUT_MS = 120000; diff --git a/src/shared/storageKeys.ts b/src/shared/storageKeys.ts new file mode 100644 index 0000000..b911bac --- /dev/null +++ b/src/shared/storageKeys.ts @@ -0,0 +1,79 @@ +import type * as vscode from "vscode"; +import type { SecretStorage } from "../auth/secretStorage"; + +/** + * Persistence keys were renamed from the old `codeviaCursor.*` prefix to + * `spider.*` when the extension was renamed. Every entry pairs the new + * (`current`) key with the pre-rename (`legacy`) one: + * + * - reads try `current` first, then fall back to `legacy`, so sessions, + * profiles and credentials created before the rename still load; + * - writes always target `current`, so new state lands under the new prefix. + * + * Legacy values are intentionally left in place: they are harmless, bounded by + * the number of pre-rename installs, and keeping them makes the fallback + * idempotent across reloads. + */ +export interface KeyPair { + readonly current: string; + readonly legacy: string; +} + +function pair(current: string, legacy: string): KeyPair { + return { current, legacy }; +} + +/** WorkspaceState / Memento keys. */ +export const STORAGE_KEYS = { + permissionRules: pair("spider.permissionRules", "codeviaCursor.permissionRules"), + sessions: pair("spider.sessions", "codeviaCursor.sessions"), + activeSession: pair("spider.activeSession", "codeviaCursor.activeSession"), + providerProfiles: pair("spider.providerProfiles", "codeviaCursor.providerProfiles"), + providerConfig: pair("spider.providerConfig", "codeviaCursor.providerConfig"), +} as const; + +/** SecretStorage keys. */ +export const SECRET_KEYS = { + cursorApiKey: pair("spider.token", "codeviaCursor.token"), + cursorSession: pair("spider.session", "codeviaCursor.session"), + openRouterApiKey: pair("spider.openrouter.key", "codeviaCursor.openrouter.key"), + webSearchApiKey: pair("spider.webSearch.apiKey", "codeviaCursor.webSearch.apiKey"), +} as const; + +/** Per-profile credential slot, mirroring the old `provider.` key. */ +export function providerSecretKeyPair(profileId: string): KeyPair { + return pair(`spider.provider.${profileId}.apiKey`, `codeviaCursor.provider.${profileId}.apiKey`); +} + +/** Reads `current`, falling back to `legacy` for pre-rename installs. */ +export function readState( + memento: vscode.Memento, + key: KeyPair, + defaultValue?: T, +): T | undefined { + const current = memento.get(key.current); + if (current !== undefined) { + return current; + } + const legacy = memento.get(key.legacy); + return legacy !== undefined ? legacy : defaultValue; +} + +/** Reads `current`, falling back to `legacy`; empty values count as absent. */ +export async function readSecret( + secrets: SecretStorage, + key: KeyPair, +): Promise { + const current = await secrets.get(key.current); + if (current && current.length > 0) { + return current; + } + const legacy = await secrets.get(key.legacy); + return legacy && legacy.length > 0 ? legacy : undefined; +} + +/** Deletes both the current and legacy slot in one call. */ +export async function deleteSecret(secrets: SecretStorage, key: KeyPair): Promise { + await secrets.delete(key.current); + await secrets.delete(key.legacy); +} diff --git a/src/webview/agentEditorPanel.ts b/src/webview/agentEditorPanel.ts new file mode 100644 index 0000000..4711fc9 --- /dev/null +++ b/src/webview/agentEditorPanel.ts @@ -0,0 +1,62 @@ +import * as vscode from "vscode"; +import type { AgentWebviewHost } from "./agentWebviewHost"; + +/** + * The Spider agent as an editor-area tab (the Codex / Cline / + * Kilo style editor view). It hosts the same GUI as the sidebar + * through the shared AgentWebviewHost, so the tab and the sidebar + * always show the same conversation and can be used side by side. + */ +export class AgentEditorPanel implements vscode.Disposable { + private static current?: AgentEditorPanel; + + private readonly disposables: vscode.Disposable[] = []; + + private constructor(private readonly panel: vscode.WebviewPanel) {} + + /** Opens the Spider editor tab (or reveals it when already open). */ + static createOrShow(extensionUri: vscode.Uri, host: AgentWebviewHost): AgentEditorPanel { + if (AgentEditorPanel.current) { + AgentEditorPanel.current.panel.reveal(vscode.ViewColumn.One); + return AgentEditorPanel.current; + } + + const panel = vscode.window.createWebviewPanel( + "spider.agentEditor", + "Spider Agent", + vscode.ViewColumn.One, + { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist", "gui")], + retainContextWhenHidden: true, + }, + ); + // Spider mark in the tab header. + panel.iconPath = vscode.Uri.joinPath(extensionUri, "assets", "spider-icon.png"); + + const instance = new AgentEditorPanel(panel); + instance.disposables.push( + panel.onDidDispose(() => { + host.detach(panel.webview); + AgentEditorPanel.current = undefined; + }), + ); + host.attach(panel.webview); + AgentEditorPanel.current = instance; + return instance; + } + + /** Reveals the open tab; false when no editor tab is open. */ + static reveal(): boolean { + if (!AgentEditorPanel.current) { + return false; + } + AgentEditorPanel.current.panel.reveal(vscode.ViewColumn.One); + return true; + } + + dispose(): void { + this.disposables.forEach((disposable) => disposable.dispose()); + this.panel.dispose(); + } +} diff --git a/src/webview/agentViewProvider.ts b/src/webview/agentViewProvider.ts index cdc83f7..e44e844 100644 --- a/src/webview/agentViewProvider.ts +++ b/src/webview/agentViewProvider.ts @@ -1,201 +1,45 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; import * as vscode from "vscode"; -import { AgentManager } from "../agent/agentManager"; -import { AgentEvent } from "../agent/agentEvents"; -import type { CursorConnection } from "../auth/cursorConnection"; -import { RuntimeManager } from "../runtime/runtimeManager"; -import type { RuntimeEvent } from "../runtime/runtimeTypes"; -import { MessageRouter } from "./messageRouter"; -import { ExtensionMessage, isExtensionMessage, SessionListItem } from "./types"; -import { EXTENSION_VERSION } from "../shared/constants"; -import type { PermissionManager } from "../permissions/permissionManager"; - +import { AgentWebviewHost } from "./agentWebviewHost"; +import { ExtensionMessage } from "./types"; + +/** + * Sidebar Agent view. All webview logic (message routing, runtime + * events, session broadcasts, the GUI markup) lives in the shared + * AgentWebviewHost so the sidebar and the editor-area Spider tab + * behave identically; this provider only attaches the view's + * webview to that host. + */ export class AgentViewProvider implements vscode.WebviewViewProvider, vscode.Disposable { - private view?: vscode.WebviewView; - private messageSubscription?: vscode.Disposable; - private eventSubscription?: vscode.Disposable; - private runtimeSubscription?: vscode.Disposable; - private viewDisposeSubscription?: vscode.Disposable; - - constructor( - private readonly extensionUri: vscode.Uri, - private readonly agentManager: AgentManager, - private readonly messageRouter: MessageRouter, - private readonly connection?: CursorConnection, - private readonly runtimeManager?: RuntimeManager, - private readonly permissionManager?: PermissionManager, - ) {} + constructor(private readonly host: AgentWebviewHost) {} resolveWebviewView( webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, ): void { - this.disposeSubscriptions(); - this.view = webviewView; - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "dist", "gui")], - }; - - webviewView.webview.html = this.getWebviewContent(webviewView.webview); - - this.messageSubscription = webviewView.webview.onDidReceiveMessage((message: unknown) => { - this.messageRouter.handleMessage(message).then( - (result) => { - if (isExtensionMessage(result) && shouldForwardResult(result.type)) { - this.postMessage(result); - } - this.postSessionList(); - }, - (error) => { - this.postMessage({ type: "AGENT_ERROR", error: String(error) }); - }, - ); - }); - - this.eventSubscription = this.agentManager.onDidPublishEvent((event: AgentEvent) => { - const extensionMessage = this.messageRouter.toExtensionMessage(event); - if (extensionMessage) { - this.postMessage(extensionMessage); - } - this.postSessionList(); - }); - - this.runtimeSubscription = this.runtimeManager?.onDidPublishEvent((event: RuntimeEvent) => { - const extensionMessage = this.messageRouter.toRuntimeExtensionMessage(event); - if (extensionMessage) { - this.postMessage(extensionMessage); - } - this.postSessionList(); - }); + this.host.attach(webviewView.webview); + webviewView.onDidDispose(() => this.host.detach(webviewView.webview)); + } - // A disposed webview can never answer a pending `ask_user` question, so - // resolve them as cancelled instead of leaving promises hanging. - this.viewDisposeSubscription = webviewView.onDidDispose(() => { - this.runtimeManager?.cancelPendingUserQuestions(); - }); + /** Opens (or reveals) the History editor tab. */ + openHistory(): void { + this.host.openHistory(); + } - this.postSessionList(); - this.postMessage(this.messageRouter.toAutoApproveStateMessage( - this.permissionManager?.getRuntimeAutoApprove() ?? { enabled: false, scope: "conversation", updatedAt: 0 }, - )); - this.postMessage({ - type: "EXTENSION_INFO", - info: { - displayName: "Spider", - version: EXTENSION_VERSION, - publisher: "codevia", - license: "MIT", - repositoryUrl: "https://github.com/codevia/codevia-cursor", - activeProvider: this.runtimeManager?.provider, - ...(this.runtimeManager?.getProviderConfig()?.modelId - ? { activeModelId: this.runtimeManager.getProviderConfig()?.modelId } - : {}), - }, - }); - void this.bootstrapAuth(); + /** Pushes the current conversation list to every surface. */ + refreshSessions(): void { + this.host.refreshSessions(); } showSettings(): void { - this.postMessage({ type: "SHOW_SETTINGS" }); + this.host.showSettings(); } postMessage(message: ExtensionMessage): void { - const promise = this.view?.webview.postMessage(message); - if (promise) { - promise.then(() => { }, () => { }); - } + this.host.postMessage(message); } dispose(): void { - this.disposeSubscriptions(); - this.view = undefined; - } - - private async bootstrapAuth(): Promise { - if (!this.connection) { - this.postMessage({ type: "AUTH_STATUS", status: "disconnected", hasKey: false }); - this.postMessage(this.messageRouter.runtimeStatus()); - return; - } - - this.postMessage({ type: "AUTH_STATUS", status: "connecting", hasKey: false }); - const status = await this.connection.restore(); - this.postMessage(status); - this.postMessage(this.messageRouter.runtimeStatus()); - } - - private postSessionList(): void { - const source = this.messageRouter.usesManagedRuntime() && this.runtimeManager - ? this.runtimeManager.listSessions() - : this.agentManager.listSessions(); - const sessions: SessionListItem[] = source.map((session) => ({ - sessionId: session.sessionId, - status: session.status, - workspacePath: session.workspacePath, - currentTask: session.currentTask, - updatedAt: session.updatedAt instanceof Date ? session.updatedAt.getTime() : undefined, - })); - - this.postMessage({ - type: "SESSION_UPDATED", - sessions, - activeSessionId: this.messageRouter.usesManagedRuntime() - ? this.runtimeManager?.activeSession?.sessionId - : this.agentManager.activeSession?.sessionId, - }); - } - - private getWebviewContent(webview: vscode.Webview): string { - const guiRoot = path.join(this.extensionUri.fsPath, "dist", "gui"); - const htmlPath = path.join(guiRoot, "index.html"); - const nonce = getNonce(); - const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "main.js")); - const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "main.css")); - - let html = fs.readFileSync(htmlPath, "utf8"); - const logoUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "spider-icon.png")); - html = html - .replaceAll("{{cspSource}}", webview.cspSource) - .replaceAll("{{nonce}}", nonce) - .replaceAll("{{scriptUri}}", scriptUri.toString()) - .replaceAll("{{styleUri}}", styleUri.toString()) - .replaceAll("{{logoUri}}", logoUri.toString()); - return html; - } - - private disposeSubscriptions(): void { - this.messageSubscription?.dispose(); - this.eventSubscription?.dispose(); - this.runtimeSubscription?.dispose(); - this.viewDisposeSubscription?.dispose(); - this.messageSubscription = undefined; - this.eventSubscription = undefined; - this.runtimeSubscription = undefined; - this.viewDisposeSubscription = undefined; - } -} - -function shouldForwardResult(type: ExtensionMessage["type"]): boolean { - return ( - type === "AUTH_STATUS" - || type === "RUNTIME_STATUS" - || type === "LOCAL_MODELS" - || type === "OPENROUTER_MODELS" - || type === "TRANSCRIPT" - || type === "TODO_UPDATED" - || type === "USER_QUESTION_CLOSED" - ); -} - -function getNonce(): string { - let text = ""; - const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - for (let i = 0; i < 32; i++) { - text += possible.charAt(Math.floor(Math.random() * possible.length)); + this.host.dispose(); } - return text; } diff --git a/src/webview/agentWebviewHost.ts b/src/webview/agentWebviewHost.ts new file mode 100644 index 0000000..b77b015 --- /dev/null +++ b/src/webview/agentWebviewHost.ts @@ -0,0 +1,313 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { AgentManager } from "../agent/agentManager"; +import { AgentEvent } from "../agent/agentEvents"; +import type { CursorConnection } from "../auth/cursorConnection"; +import { RuntimeManager } from "../runtime/runtimeManager"; +import type { RuntimeEvent } from "../runtime/runtimeTypes"; +import { MessageRouter } from "./messageRouter"; +import { HistoryPanel } from "./historyPanel"; +import { AgentEditorPanel } from "./agentEditorPanel"; +import { ExtensionMessage, isExtensionMessage, SessionListItem } from "./types"; +import { EXTENSION_NAME, EXTENSION_VERSION } from "../shared/constants"; +import { Logger } from "../utils/logger"; +import type { PermissionManager } from "../permissions/permissionManager"; +import type { TranscriptEntry } from "../session/transcriptStore"; + +/** + * Bridges the agent runtime to every Spider webview surface (the + * sidebar Agent view and the editor-area Spider tab). Surfaces + * attach their webview; messages, runtime events and session + * updates broadcast to all of them, so sidebar and editor always + * show the same conversation and can be used side by side. + */ +export class AgentWebviewHost implements vscode.Disposable { + private static readonly logger = new Logger(EXTENSION_NAME, "INFO"); + /** Attached webviews (sidebar view, editor tab) and their subscriptions. */ + private readonly attached = new Map(); + private eventSubscription?: vscode.Disposable; + private runtimeSubscription?: vscode.Disposable; + private bootstrapped = false; + private pendingShowSettings = false; + + constructor( + private readonly extensionUri: vscode.Uri, + private readonly agentManager: AgentManager, + private readonly messageRouter: MessageRouter, + private readonly connection?: CursorConnection, + private readonly runtimeManager?: RuntimeManager, + private readonly permissionManager?: PermissionManager, + ) { + // Runtime events broadcast to every attached surface. + this.eventSubscription = this.agentManager.onDidPublishEvent((event: AgentEvent) => { + const extensionMessage = this.messageRouter.toExtensionMessage(event); + if (extensionMessage) { + this.postMessage(extensionMessage); + } + this.postSessionList(); + }); + + this.runtimeSubscription = this.runtimeManager?.onDidPublishEvent((event: RuntimeEvent) => { + const extensionMessage = this.messageRouter.toRuntimeExtensionMessage(event); + if (extensionMessage) { + this.postMessage(extensionMessage); + } + this.postSessionList(); + }); + } + + /** Attaches a webview surface (sidebar view or editor tab). */ + attach(webview: vscode.Webview): void { + if (this.attached.has(webview)) { + return; + } + AgentWebviewHost.logger.info("Agent webview resolved", { operation: "attach" }); + + // openSettings can run before any surface exists. The flag is + // carried in the HTML (not a postMessage) so a freshly created + // webview reliably opens in Settings even before its script loads. + const showSettings = this.pendingShowSettings; + this.pendingShowSettings = false; + + webview.options = { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "dist", "gui")], + }; + + webview.html = this.getWebviewContent(webview, showSettings); + + const subscriptions: vscode.Disposable[] = [ + webview.onDidReceiveMessage((message: unknown) => { + if (isOpenHistoryMessage(message)) { + this.openHistory(); + return; + } + if (isOpenAgentEditorMessage(message)) { + // Clicking the Spider logo (sidebar or editor tab): + // open or focus the editor-area Spider tab. + AgentEditorPanel.createOrShow(this.extensionUri, this); + return; + } + this.messageRouter.handleMessage(message).then( + (result) => { + if (isExtensionMessage(result) && shouldForwardResult(result.type)) { + this.postMessage(result); + } + this.postSessionList(); + }, + (error) => { + this.postMessage({ type: "AGENT_ERROR", error: String(error) }); + }, + ); + }), + ]; + this.attached.set(webview, subscriptions); + + if (!this.bootstrapped) { + this.bootstrapped = true; + void this.bootstrapAuth(); + } + this.postSessionList(); + this.postMessage(this.messageRouter.toAutoApproveStateMessage( + this.permissionManager?.getRuntimeAutoApprove() ?? { enabled: false, scope: "conversation", updatedAt: 0 }, + )); + this.postMessage({ + type: "EXTENSION_INFO", + info: { + displayName: "Spider", + version: EXTENSION_VERSION, + publisher: "codevia", + license: "MIT", + repositoryUrl: "https://github.com/codevia/codevia-cursor", + activeProvider: this.runtimeManager?.provider, + ...(this.runtimeManager?.getProviderConfig()?.modelId + ? { activeModelId: this.runtimeManager.getProviderConfig()?.modelId } + : {}), + }, + }); + } + + /** Detaches a disposed surface. */ + detach(webview: vscode.Webview): void { + const subscriptions = this.attached.get(webview); + if (!subscriptions) { + return; + } + subscriptions.forEach((subscription) => subscription.dispose()); + this.attached.delete(webview); + // A disposed webview can never answer a pending `ask_user` + // question; with several surfaces, only cancel once the last + // one is gone (another surface may still answer). + if (this.attached.size === 0) { + this.runtimeManager?.cancelPendingUserQuestions(); + } + } + + /** Opens (or reveals) the History editor tab. */ + openHistory(): void { + HistoryPanel.createOrShow(this.extensionUri, { + listSessions: () => ({ + sessions: this.collectSessions(), + activeSessionId: this.activeSessionId(), + }), + selectSession: async (sessionId) => { + await this.messageRouter.handleMessage({ type: "SELECT_SESSION", sessionId }); + // Reveal the Spider editor tab when it is open; otherwise + // focus the sidebar container. The GUI loads the new + // transcript as soon as it sees a different activeSessionId. + if (!AgentEditorPanel.reveal()) { + await vscode.commands.executeCommand("workbench.view.extension.spider"); + } + this.refreshSessions(); + }, + deleteSession: async (sessionId) => { + await this.messageRouter.handleMessage({ type: "DELETE_SESSION", sessionId }); + this.refreshSessions(); + }, + getTranscript: async (sessionId) => { + const result = await this.messageRouter.handleMessage({ type: "GET_TRANSCRIPT", sessionId }); + return isTranscriptMessage(result) ? result.entries : []; + }, + }); + } + + /** Pushes the current conversation list to every surface. */ + refreshSessions(): void { + this.postSessionList(); + } + + showSettings(): void { + if (this.attached.size > 0) { + this.postMessage({ type: "SHOW_SETTINGS" }); + } else { + // No surface resolved yet; remember it until attach() runs. + this.pendingShowSettings = true; + } + } + + postMessage(message: ExtensionMessage): void { + for (const webview of this.attached.keys()) { + const promise = webview.postMessage(message); + if (promise) { + promise.then(() => { }, () => { }); + } + } + } + + dispose(): void { + this.eventSubscription?.dispose(); + this.runtimeSubscription?.dispose(); + for (const webview of [...this.attached.keys()]) { + this.detach(webview); + } + } + + private async bootstrapAuth(): Promise { + if (!this.connection) { + this.postMessage({ type: "AUTH_STATUS", status: "disconnected", hasKey: false }); + this.postMessage(this.messageRouter.runtimeStatus()); + return; + } + + this.postMessage({ type: "AUTH_STATUS", status: "connecting", hasKey: false }); + const status = await this.connection.restore(); + this.postMessage(status); + this.postMessage(this.messageRouter.runtimeStatus()); + } + + private postSessionList(): void { + this.postMessage({ + type: "SESSION_UPDATED", + sessions: this.collectSessions(), + activeSessionId: this.activeSessionId(), + }); + } + + /** Single source of truth for the conversation list (all surfaces). */ + private collectSessions(): SessionListItem[] { + const source = this.messageRouter.usesManagedRuntime() && this.runtimeManager + ? this.runtimeManager.listSessions() + : this.agentManager.listSessions(); + return source.map((session) => ({ + sessionId: session.sessionId, + status: session.status, + workspacePath: session.workspacePath, + currentTask: session.currentTask, + updatedAt: session.updatedAt instanceof Date ? session.updatedAt.getTime() : undefined, + })); + } + + private activeSessionId(): string | undefined { + return this.messageRouter.usesManagedRuntime() + ? this.runtimeManager?.activeSession?.sessionId + : this.agentManager.activeSession?.sessionId; + } + + private getWebviewContent(webview: vscode.Webview, showSettings: boolean): string { + const guiRoot = path.join(this.extensionUri.fsPath, "dist", "gui"); + const htmlPath = path.join(guiRoot, "index.html"); + const nonce = getNonce(); + const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "main.js")); + const styleUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "main.css")); + + let html = fs.readFileSync(htmlPath, "utf8"); + const logoUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "spider-icon.png")); + html = html + .replaceAll("{{cspSource}}", webview.cspSource) + .replaceAll("{{nonce}}", nonce) + .replaceAll("{{scriptUri}}", scriptUri.toString()) + .replaceAll("{{styleUri}}", styleUri.toString()) + .replaceAll("{{logoUri}}", logoUri.toString()) + .replaceAll("{{showSettings}}", showSettings ? "1" : "0"); + return html; + } +} + +function shouldForwardResult(type: ExtensionMessage["type"]): boolean { + return ( + type === "AUTH_STATUS" + || type === "RUNTIME_STATUS" + || type === "LOCAL_MODELS" + || type === "OPENROUTER_MODELS" + || type === "TRANSCRIPT" + || type === "TODO_UPDATED" + || type === "USER_QUESTION_CLOSED" + ); +} + +function isTranscriptMessage( + value: unknown, +): value is { type: "TRANSCRIPT"; entries: readonly TranscriptEntry[] } { + return ( + typeof value === "object" + && value !== null + && (value as { type?: unknown }).type === "TRANSCRIPT" + && Array.isArray((value as { entries?: unknown }).entries) + ); +} + +function isOpenHistoryMessage(value: unknown): value is { type: "OPEN_HISTORY" } { + return ( + typeof value === "object" + && value !== null + && (value as { type?: unknown }).type === "OPEN_HISTORY" + ); +} + +function isOpenAgentEditorMessage(value: unknown): value is { type: "OPEN_AGENT_EDITOR" } { + return ( + typeof value === "object" + && value !== null + && (value as { type?: unknown }).type === "OPEN_AGENT_EDITOR" + ); +} + +function getNonce(): string { + let text = ""; + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/src/webview/historyPanel.ts b/src/webview/historyPanel.ts new file mode 100644 index 0000000..6a07280 --- /dev/null +++ b/src/webview/historyPanel.ts @@ -0,0 +1,169 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { EXTENSION_NAME } from "../shared/constants"; +import type { TranscriptEntry } from "../session/transcriptStore"; +import type { ExtensionMessage, SessionListItem } from "./types"; + +/** + * Everything the History panel needs from the sidebar host. Keeping this an + * interface (rather than reaching into AgentViewProvider) means the panel is + * testable and the sidebar owns exactly two responsibilities: report the + * conversation list and switch the active conversation. + */ +export interface HistoryPanelHost { + listSessions(): { sessions: SessionListItem[]; activeSessionId?: string }; + /** Switches the sidebar to this conversation and reveals the Agent view. */ + selectSession(sessionId: string): Promise; + /** Permanently removes the conversation and its persisted transcript. */ + deleteSession(sessionId: string): Promise; + /** Read-only transcript for the preview pane. */ + getTranscript(sessionId: string): Promise; +} + +/** + * Conversation History as a real editor tab (a `WebviewPanel`), not a sidebar + * dropdown: VS Code keeps it open beside the editor, survives sidebar switches, + * and the user can move it to any column. Selecting a conversation delegates to + * the sidebar host, which focuses the Agent view and loads the transcript there + * — history itself stays read-only. + */ +export class HistoryPanel implements vscode.Disposable { + private static current?: HistoryPanel; + + static createOrShow(extensionUri: vscode.Uri, host: HistoryPanelHost): HistoryPanel { + if (HistoryPanel.current) { + HistoryPanel.current.panel.reveal(); + HistoryPanel.current.postSessions(); + return HistoryPanel.current; + } + const panel = vscode.window.createWebviewPanel( + "spider.history", + `${EXTENSION_NAME}: History`, + vscode.ViewColumn.Active, + { + enableScripts: true, + localResourceRoots: [vscode.Uri.joinPath(extensionUri, "dist", "gui")], + }, + ); + HistoryPanel.current = new HistoryPanel(panel, extensionUri, host); + return HistoryPanel.current; + } + + private readonly disposables: vscode.Disposable[] = []; + + private constructor( + private readonly panel: vscode.WebviewPanel, + private readonly extensionUri: vscode.Uri, + private readonly host: HistoryPanelHost, + ) { + this.panel.webview.html = this.render(this.panel.webview); + this.panel.onDidDispose(() => this.dispose(), null, this.disposables); + this.panel.webview.onDidReceiveMessage( + (message: unknown) => { + void this.handleMessage(message); + }, + null, + this.disposables, + ); + this.postSessions(); + } + + dispose(): void { + if (HistoryPanel.current === this) { + HistoryPanel.current = undefined; + } + while (this.disposables.length > 0) { + this.disposables.pop()?.dispose(); + } + this.panel.dispose(); + } + + /** Re-broadcasts the conversation list (kept live as sessions change). */ + postSessions(): void { + const { sessions, activeSessionId } = this.host.listSessions(); + void this.panel.webview.postMessage({ + type: "SESSION_UPDATED", + sessions, + activeSessionId, + } satisfies ExtensionMessage); + } + + private async handleMessage(message: unknown): Promise { + if (!isRecord(message) || typeof message.type !== "string") { + return; + } + switch (message.type) { + case "LIST_SESSIONS": + this.postSessions(); + return; + case "SELECT_SESSION": { + if (!isSessionId(message.sessionId)) { + return; + } + await this.host.selectSession(message.sessionId); + this.postSessions(); + return; + } + case "DELETE_SESSION": { + if (!isSessionId(message.sessionId)) { + return; + } + await this.host.deleteSession(message.sessionId); + this.postSessions(); + return; + } + case "GET_TRANSCRIPT": { + if (!isSessionId(message.sessionId)) { + return; + } + const entries = await this.host.getTranscript(message.sessionId); + void this.panel.webview.postMessage({ + type: "TRANSCRIPT", + sessionId: message.sessionId, + // The host returns a readonly view; the wire message is a plain array. + entries: [...entries], + } satisfies ExtensionMessage); + return; + } + default: + return; + } + } + + private render(webview: vscode.Webview): string { + const guiRoot = path.join(this.extensionUri.fsPath, "dist", "gui"); + const htmlPath = path.join(guiRoot, "history.html"); + const nonce = getNonce(); + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "history.js"), + ); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(this.extensionUri, "dist", "gui", "main.css"), + ); + + return fs + .readFileSync(htmlPath, "utf8") + .replaceAll("{{cspSource}}", webview.cspSource) + .replaceAll("{{nonce}}", nonce) + .replaceAll("{{scriptUri}}", scriptUri.toString()) + .replaceAll("{{styleUri}}", styleUri.toString()); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isSessionId(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function getNonce(): string { + let text = ""; + const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + for (let i = 0; i < 32; i++) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +} diff --git a/src/webview/messageRouter.ts b/src/webview/messageRouter.ts index ed45ea0..f629996 100644 --- a/src/webview/messageRouter.ts +++ b/src/webview/messageRouter.ts @@ -11,7 +11,8 @@ import { createProviderCredentialStore, type ProviderCredentialStore } from "../ import { profileIdFor } from "../session/providerConfigStore"; import type { PermissionManager } from "../permissions/permissionManager"; import { isPermissionRule, isPermissionRuleCategory } from "./permissionRules"; -import { EXTENSION_VERSION, OPENROUTER_API_KEY_SECRET_KEY } from "../shared/constants"; +import { EXTENSION_VERSION } from "../shared/constants"; +import { SECRET_KEYS } from "../shared/storageKeys"; import { AgentState, ExtensionMessage, @@ -44,7 +45,7 @@ export class MessageRouter { this.permissionManager = dependencies?.permissionManager; this.providerCredentials = openRouterSecrets ? createProviderCredentialStore(openRouterSecrets, { - legacySecretKeys: { [profileIdFor("openrouter")]: OPENROUTER_API_KEY_SECRET_KEY }, + legacySecretKeys: { [profileIdFor("openrouter")]: SECRET_KEYS.openRouterApiKey.legacy }, }) : undefined; } @@ -148,6 +149,24 @@ export class MessageRouter { } return { success: true, sessions: this.agentManager.listSessions() }; } + case "OPEN_HISTORY": + // Handled by the webview host (it owns the editor panel); kept in the + // exhaustive switch so a direct call is a documented no-op. + return { success: true }; + case "OPEN_AGENT_EDITOR": + // Handled by the webview host (it owns the editor panel); kept in the + // exhaustive switch so a direct call is a documented no-op. + return { success: true }; + case "DELETE_SESSION": { + // History Delete: cancel any run, drop the session and erase its + // transcript so it cannot reappear in the list after a restart. + if (this.usesManagedRuntime() && this.runtimeManager) { + await this.runtimeManager.deleteSession(typed.sessionId); + } else { + await this.agentManager.deleteSession(typed.sessionId); + } + return { success: true }; + } case "GET_TRANSCRIPT": { if (this.usesManagedRuntime() && this.runtimeManager) { const entries = await this.runtimeManager.loadTranscript(typed.sessionId); @@ -768,6 +787,7 @@ export class MessageRouter { } return message as WebviewMessage; case "GET_EXTENSION_INFO": + case "OPEN_AGENT_EDITOR": return message as WebviewMessage; case "CONNECT_CURSOR": if (typed.apiKey !== undefined && typeof typed.apiKey !== "string") { diff --git a/src/webview/types.ts b/src/webview/types.ts index 5a8c324..3fbde49 100644 --- a/src/webview/types.ts +++ b/src/webview/types.ts @@ -34,6 +34,12 @@ export type WebviewMessage = | { type: "DENY_PERMISSION"; requestId: string } | { type: "TRY_AGAIN"; sessionId: string } | { type: "LIST_SESSIONS" } + /** Opens the conversation History editor tab. */ + | { type: "OPEN_HISTORY" } + /** Clicking the Spider logo opens (or focuses) the editor-area tab. */ + | { type: "OPEN_AGENT_EDITOR" } + /** History tab: permanently removes a conversation and its transcript. */ + | { type: "DELETE_SESSION"; sessionId: string } /** Composer shield: enable/disable the temporary runtime auto-approve. */ | { type: "SET_RUNTIME_AUTO_APPROVE"; enabled: boolean; scope?: "conversation" | "runtime" } | { type: "GET_PERMISSION_RULES" } diff --git a/test/integration/extension.test.ts b/test/integration/extension.test.ts index f1bd49d..f609943 100644 --- a/test/integration/extension.test.ts +++ b/test/integration/extension.test.ts @@ -8,9 +8,9 @@ suite("Extension Test Suite", () => { test("activates and registers commands", async () => { const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("codeviaCursor.openAgent"), "openAgent command should be registered"); - assert.ok(commands.includes("codeviaCursor.openSettings"), "openSettings command should be registered"); + assert.ok(commands.includes("spider.openAgent"), "openAgent command should be registered"); + assert.ok(commands.includes("spider.openSettings"), "openSettings command should be registered"); - await vscode.commands.executeCommand("codeviaCursor.openAgent"); + await vscode.commands.executeCommand("spider.openAgent"); }); }); diff --git a/test/unit/agent/agentManager.test.ts b/test/unit/agent/agentManager.test.ts index 30c98d6..e040943 100644 --- a/test/unit/agent/agentManager.test.ts +++ b/test/unit/agent/agentManager.test.ts @@ -36,7 +36,7 @@ afterEach(() => { function createStore(sessions: unknown[] = []) { const workspaceState = { - get: vi.fn((key: string) => key === "codeviaCursor.sessions" ? sessions : undefined), + get: vi.fn((key: string) => key === "spider.sessions" ? sessions : undefined), update: vi.fn().mockResolvedValue(undefined), }; const store = new SessionStore(workspaceState as unknown as vscode.Memento); @@ -230,7 +230,7 @@ describe("AgentManager", () => { }, ]; const workspaceState = { - get: vi.fn((key: string) => key === "codeviaCursor.sessions" ? stored : "session-1"), + get: vi.fn((key: string) => key === "spider.sessions" ? stored : "session-1"), update: vi.fn().mockResolvedValue(undefined), }; const store = new SessionStore(workspaceState as unknown as vscode.Memento); @@ -240,7 +240,7 @@ describe("AgentManager", () => { await manager.restoreSessions(); expect(manager.activeSession?.sessionId).toBe("session-1"); - expect(workspaceState.update).toHaveBeenCalledWith("codeviaCursor.activeSession", "session-1"); + expect(workspaceState.update).toHaveBeenCalledWith("spider.activeSession", "session-1"); }); it("restoreSessions publishes a disconnected event for interrupted sessions", async () => { @@ -275,7 +275,7 @@ describe("AgentManager", () => { manager.selectSession(session.sessionId); await Promise.resolve(); - expect(workspaceState.update).toHaveBeenCalledWith("codeviaCursor.activeSession", session.sessionId); + expect(workspaceState.update).toHaveBeenCalledWith("spider.activeSession", session.sessionId); }); it("startTask transitions through states and publishes events", async () => { diff --git a/test/unit/auth/cursorAuthProvider.test.ts b/test/unit/auth/cursorAuthProvider.test.ts index 6f2a32a..4f6f418 100644 --- a/test/unit/auth/cursorAuthProvider.test.ts +++ b/test/unit/auth/cursorAuthProvider.test.ts @@ -40,7 +40,7 @@ describe("CursorAuthProvider", () => { expect(session.accessToken).toBe("token"); expect(session.scopes).toEqual(["api"]); expect(secretStorage.store).toHaveBeenCalledWith( - "codeviaCursor.session", + "spider.session", expect.stringContaining("token"), ); }); diff --git a/test/unit/auth/cursorClient.test.ts b/test/unit/auth/cursorClient.test.ts index df85b56..50c0641 100644 --- a/test/unit/auth/cursorClient.test.ts +++ b/test/unit/auth/cursorClient.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import { configureCursorSdk } from "@cursor/sdk"; import { CursorClient } from "../../../src/auth/cursorClient"; import { CursorAuthError } from "../../../src/auth/cursorAuthError"; import { Agent, Run } from "@cursor/sdk"; @@ -130,4 +131,21 @@ describe("CursorClient", () => { const client = new CursorClient("bad"); await expect(client.validateConnection("/workspace")).rejects.toBeInstanceOf(CursorAuthError); }); + + it("does not configure the SDK at construction (activation must not depend on Cursor)", () => { + new CursorClient(undefined); + + expect(configureCursorSdk).not.toHaveBeenCalled(); + }); + + it("configures the SDK exactly once on first use", async () => { + vi.mocked(Agent.list).mockResolvedValue({ items: [] }); + + const client = new CursorClient(undefined); + await client.listAgents(); + await client.listAgents(); + + expect(configureCursorSdk).toHaveBeenCalledTimes(1); + expect(configureCursorSdk).toHaveBeenCalledWith({}); + }); }); diff --git a/test/unit/auth/providerCredentials.test.ts b/test/unit/auth/providerCredentials.test.ts index 0a3421b..c228b7f 100644 --- a/test/unit/auth/providerCredentials.test.ts +++ b/test/unit/auth/providerCredentials.test.ts @@ -32,7 +32,7 @@ function makeStore(initial: Record = {}) { describe("providerCredentials", () => { it("namespaces the secret key by profile id", () => { - expect(providerSecretKey("openrouter-default")).toBe("codeviaCursor.provider.openrouter-default.apiKey"); + expect(providerSecretKey("openrouter-default")).toBe("spider.provider.openrouter-default.apiKey"); expect(providerSecretKey("ollama-default")).not.toBe(providerSecretKey("openrouter-default")); }); @@ -45,8 +45,8 @@ describe("providerCredentials", () => { expect(await credentials.get("ollama-http-127-0-0-1-11434")).toBe("local-token"); expect(await credentials.get("other-profile")).toBeUndefined(); expect(Object.keys(values).sort()).toEqual([ - "codeviaCursor.provider.ollama-http-127-0-0-1-11434.apiKey", - "codeviaCursor.provider.openrouter-default.apiKey", + "spider.provider.ollama-http-127-0-0-1-11434.apiKey", + "spider.provider.openrouter-default.apiKey", ]); }); @@ -90,4 +90,25 @@ describe("providerCredentials", () => { const { credentials } = makeStore({ [providerSecretKey(OPENROUTER_PROFILE)]: "" }); expect(await credentials.get(OPENROUTER_PROFILE)).toBeUndefined(); }); + + it("falls back to the pre-rename provider slot for the same profile", async () => { + const preRenameKey = "codeviaCursor.provider.openrouter-default.apiKey"; + const { credentials } = makeStore({ [preRenameKey]: "sk-pre-rename" }); + + expect(await credentials.get(OPENROUTER_PROFILE)).toBe("sk-pre-rename"); + + await credentials.store(OPENROUTER_PROFILE, "sk-new"); + + expect(await credentials.get(OPENROUTER_PROFILE)).toBe("sk-new"); + }); + + it("clears the pre-rename provider slot on delete", async () => { + const preRenameKey = "codeviaCursor.provider.openrouter-default.apiKey"; + const { credentials, values, storage } = makeStore({ [preRenameKey]: "sk-pre-rename" }); + + await credentials.delete(OPENROUTER_PROFILE); + + expect(values).toEqual({}); + expect(storage.delete).toHaveBeenCalledWith(preRenameKey); + }); }); diff --git a/test/unit/chat/chatParticipant.test.ts b/test/unit/chat/chatParticipant.test.ts new file mode 100644 index 0000000..23d5299 --- /dev/null +++ b/test/unit/chat/chatParticipant.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from "vitest"; + +const registerMock = vi.hoisted(() => ({ + createChatParticipant: vi.fn(), +})); + +vi.mock("vscode", () => ({ + chat: { + createChatParticipant: (id: string, handler: unknown) => { + registerMock.createChatParticipant(id, handler); + return { id, dispose: vi.fn(), iconPath: undefined }; + }, + }, + Uri: { joinPath: (...parts: unknown[]) => parts.join("/") }, +})); + +import { registerSpiderChatParticipant } from "../../../src/chat/chatParticipant"; + +interface FakeRuntime { + provider?: string; + getSession: ReturnType; + createSession: ReturnType; + onDidPublishEvent: (listener: (event: unknown) => void) => { dispose: () => void }; + startTask: ReturnType; + cancelTask: ReturnType; + resolveUserQuestion: ReturnType; + cancelUserQuestion: ReturnType; +} + +function createRuntime( + onTask: (sessionId: string, emit: (event: Record) => void) => void, + provider = "ollama", +): { runtime: FakeRuntime; emitSubscribers: Array<(event: unknown) => void> } { + const subscribers: Array<(event: unknown) => void> = []; + const runtime: FakeRuntime = { + provider, + getSession: vi.fn(() => undefined), + createSession: vi.fn(() => ({ sessionId: "chat-1" })), + onDidPublishEvent: (listener) => { + subscribers.push(listener); + return { dispose: () => undefined }; + }, + startTask: vi.fn(async (sessionId: string) => { + onTask(sessionId, (event) => { + for (const listener of subscribers) { + listener(event); + } + }); + }), + cancelTask: vi.fn(async () => undefined), + resolveUserQuestion: vi.fn(() => true), + cancelUserQuestion: vi.fn(() => true), + }; + return { runtime, emitSubscribers: subscribers }; +} + +function createStream() { + return { markdown: vi.fn(), progress: vi.fn(), button: vi.fn() }; +} + +function createToken() { + return { onCancellationRequested: () => ({ dispose: vi.fn() }) }; +} + +function request(prompt: string, command?: string) { + return { prompt, command } as never; +} + +function register(runtime: FakeRuntime, resolveDecision = vi.fn()) { + registerMock.createChatParticipant.mockClear(); + registerSpiderChatParticipant({ + extensionUri: "file:///ext" as never, + runtimeManager: runtime as never, + permissionManager: { resolveDecision } as never, + defaultWorkspacePath: "/workspace", + }); + const [, handler] = registerMock.createChatParticipant.mock.calls[0] as [ + string, + (request: unknown, context: unknown, stream: unknown, token: unknown) => Promise, + ]; + return { handler, resolveDecision }; +} + +describe("spider chat participant", () => { + it("registers under the contributed participant id", () => { + const { runtime } = createRuntime(() => undefined); + register(runtime); + expect(registerMock.createChatParticipant).toHaveBeenCalledWith( + "spider.spider", + expect.any(Function), + ); + }); + + it("explains how to configure a provider when none is set", async () => { + // Empty provider stands in for "never configured" (falsy). + const { runtime } = createRuntime(() => undefined, ""); + const { handler } = register(runtime); + const stream = createStream(); + + await handler(request("hello"), {}, stream, createToken()); + + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("no AI provider configured")); + expect(runtime.startTask).not.toHaveBeenCalled(); + }); + + it("streams text deltas and does not duplicate the final assistant message", async () => { + const { runtime } = createRuntime((sessionId, emit) => { + emit({ type: "text_delta", sessionId, text: "Hello ", timestamp: 0 }); + emit({ type: "text_delta", sessionId, text: "world", timestamp: 0 }); + emit({ type: "assistant_message", sessionId, message: "Hello world", timestamp: 0 }); + }); + const { handler } = register(runtime); + const stream = createStream(); + + await handler(request("hi"), {}, stream, createToken()); + + expect(stream.markdown).toHaveBeenNthCalledWith(1, "Hello "); + expect(stream.markdown).toHaveBeenNthCalledWith(2, "world"); + expect(stream.markdown).not.toHaveBeenCalledWith("Hello world"); + }); + + it("falls back to the final message when the provider did not stream", async () => { + const { runtime } = createRuntime((sessionId, emit) => { + emit({ type: "assistant_message", sessionId, message: "Full reply", timestamp: 0 }); + }); + const { handler } = register(runtime); + const stream = createStream(); + + await handler(request("hi"), {}, stream, createToken()); + + expect(stream.markdown).toHaveBeenCalledWith("Full reply"); + }); + + it("passes slash-command intent through as a prompt preamble", async () => { + const { runtime } = createRuntime(() => undefined); + const { handler } = register(runtime); + + await handler(request("this function", "explain"), {}, createStream(), createToken()); + + expect(runtime.startTask).toHaveBeenCalledWith( + "chat-1", + expect.stringContaining("Explain the selected code"), + expect.anything(), + ); + }); + + it("auto-approves non-destructive permission requests", async () => { + const { runtime } = createRuntime((sessionId, emit) => { + emit({ + type: "permission_request", + sessionId, + request: { requestId: "p1", sessionId, category: "EXECUTE", description: "run", destructive: false }, + timestamp: 0, + }); + }); + const { handler, resolveDecision } = register(runtime); + const stream = createStream(); + + await handler(request("do it"), {}, stream, createToken()); + + expect(resolveDecision).toHaveBeenCalledWith({ requestId: "p1", decision: "ALLOW", confirmation: true }); + expect(stream.button).not.toHaveBeenCalled(); + }); + + it("asks the user before a destructive tool instead of denying it", async () => { + const { runtime } = createRuntime((sessionId, emit) => { + emit({ + type: "permission_request", + sessionId, + request: { requestId: "p2", sessionId, category: "DESTRUCTIVE", description: "rm -rf build", destructive: true }, + timestamp: 0, + }); + }); + const { handler, resolveDecision } = register(runtime); + const stream = createStream(); + + await handler(request("clean up"), {}, stream, createToken()); + + expect(stream.markdown).toHaveBeenCalledWith(expect.stringContaining("Permission required")); + expect(stream.button).toHaveBeenCalledWith({ + command: "spider.chatPermission.allow", + title: "Allow once", + arguments: ["p2", "rm -rf build"], + }); + expect(stream.button).toHaveBeenCalledWith({ + command: "spider.chatPermission.deny", + title: "Deny", + arguments: ["p2", "rm -rf build"], + }); + // The decision belongs to the user's button click, not to the participant. + expect(resolveDecision).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/session/providerConfigStore.test.ts b/test/unit/session/providerConfigStore.test.ts index 41dae83..70b554a 100644 --- a/test/unit/session/providerConfigStore.test.ts +++ b/test/unit/session/providerConfigStore.test.ts @@ -36,7 +36,10 @@ function createMemento(values: Record = {}) { }; } +/** Pre-rename key: migration source, read through the storage fallback. */ const LEGACY_KEY = "codeviaCursor.providerConfig"; +/** Current key the active profile is projected into. */ +const PROJECTION_KEY = "spider.providerConfig"; describe("ProviderConfigStore", () => { it("returns undefined when nothing is saved", () => { @@ -57,9 +60,9 @@ describe("ProviderConfigStore", () => { await store.save(config); - // The legacy projection stays exactly as it was: older builds and every - // existing consumer keep working. - const persisted = values[LEGACY_KEY] as Record; + // The single-slot projection is written under the current key; the + // pre-rename key is only read as a fallback. + const persisted = values[PROJECTION_KEY] as Record; expect(persisted).toEqual({ provider: "openai-compatible", baseUrl: "http://127.0.0.1:1234/v1", diff --git a/test/unit/session/sessionStore.test.ts b/test/unit/session/sessionStore.test.ts index 0cbe880..f8ebe27 100644 --- a/test/unit/session/sessionStore.test.ts +++ b/test/unit/session/sessionStore.test.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from "vitest"; import { AgentSession } from "../../../src/agent/agentSession"; import { SessionStore } from "../../../src/session/sessionStore"; -const SESSIONS_KEY = "codeviaCursor.sessions"; -const ACTIVE_SESSION_KEY = "codeviaCursor.activeSession"; +const SESSIONS_KEY = "spider.sessions"; +const ACTIVE_SESSION_KEY = "spider.activeSession"; function createMemento(values: Record = {}) { return { @@ -18,7 +18,7 @@ describe("SessionStore", () => { const store = new SessionStore(workspaceState); expect(store.loadSessions()).toEqual([]); - expect(workspaceState.get).toHaveBeenCalledWith(SESSIONS_KEY, []); + expect(workspaceState.get).toHaveBeenCalledWith(SESSIONS_KEY); }); it("deserializes stored sessions and dates", () => { diff --git a/test/unit/shared/constants.test.ts b/test/unit/shared/constants.test.ts index 10c4c80..3b5406c 100644 --- a/test/unit/shared/constants.test.ts +++ b/test/unit/shared/constants.test.ts @@ -3,7 +3,7 @@ import { COMMANDS, EXTENSION_ID, EXTENSION_NAME } from "../../../src/shared/cons describe("constants", () => { it("exports the extension id", () => { - expect(EXTENSION_ID).toBe("codevia-cursor"); + expect(EXTENSION_ID).toBe("spider"); }); it("exports the extension display name", () => { @@ -11,7 +11,7 @@ describe("constants", () => { }); it("exports stable command ids", () => { - expect(COMMANDS.openAgent).toBe("codeviaCursor.openAgent"); - expect(COMMANDS.openSettings).toBe("codeviaCursor.openSettings"); + expect(COMMANDS.openAgent).toBe("spider.openAgent"); + expect(COMMANDS.openSettings).toBe("spider.openSettings"); }); }); diff --git a/test/unit/shared/storageKeys.test.ts b/test/unit/shared/storageKeys.test.ts new file mode 100644 index 0000000..b2e8d87 --- /dev/null +++ b/test/unit/shared/storageKeys.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it, vi } from "vitest"; +import type * as vscode from "vscode"; +import type { SecretStorage } from "../../../src/auth/secretStorage"; +import { + SECRET_KEYS, + STORAGE_KEYS, + deleteSecret, + providerSecretKeyPair, + readSecret, + readState, +} from "../../../src/shared/storageKeys"; + +function createMemento(values: Record = {}) { + return { + get: vi.fn((key: string) => values[key]), + update: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.Memento; +} + +function createSecrets(initial: Record = {}) { + const values: Record = { ...initial }; + const storage = { + get: vi.fn(async (key: string) => values[key]), + store: vi.fn(async (key: string, value: string) => { + values[key] = value; + }), + delete: vi.fn(async (key: string) => { + delete values[key]; + }), + }; + return { secrets: storage as unknown as SecretStorage, values, storage }; +} + +describe("storageKeys", () => { + describe("key pairs", () => { + it("renames every workspace key under the spider.* prefix", () => { + for (const key of Object.values(STORAGE_KEYS)) { + expect(key.current.startsWith("spider.")).toBe(true); + expect(key.legacy.startsWith("codeviaCursor.")).toBe(true); + } + }); + + it("renames every secret key under the spider.* prefix", () => { + for (const key of Object.values(SECRET_KEYS)) { + expect(key.current.startsWith("spider.")).toBe(true); + expect(key.legacy.startsWith("codeviaCursor.")).toBe(true); + } + }); + + it("derives the per-profile credential pair from the profile id", () => { + expect(providerSecretKeyPair("openrouter-default")).toEqual({ + current: "spider.provider.openrouter-default.apiKey", + legacy: "codeviaCursor.provider.openrouter-default.apiKey", + }); + }); + }); + + describe("readState", () => { + it("prefers the current key", () => { + const memento = createMemento({ + [STORAGE_KEYS.sessions.current]: ["new"], + [STORAGE_KEYS.sessions.legacy]: ["old"], + }); + expect(readState(memento, STORAGE_KEYS.sessions)).toEqual(["new"]); + }); + + it("falls back to the legacy key for pre-rename installs", () => { + const memento = createMemento({ [STORAGE_KEYS.sessions.legacy]: ["old"] }); + expect(readState(memento, STORAGE_KEYS.sessions)).toEqual(["old"]); + }); + + it("returns the default when neither key is present", () => { + const memento = createMemento(); + expect(readState(memento, STORAGE_KEYS.sessions, [])).toEqual([]); + }); + }); + + describe("readSecret", () => { + it("prefers the current key", async () => { + const { secrets } = createSecrets({ + [SECRET_KEYS.cursorApiKey.current]: "new", + [SECRET_KEYS.cursorApiKey.legacy]: "old", + }); + expect(await readSecret(secrets, SECRET_KEYS.cursorApiKey)).toBe("new"); + }); + + it("falls back to the legacy key", async () => { + const { secrets } = createSecrets({ [SECRET_KEYS.cursorApiKey.legacy]: "old" }); + expect(await readSecret(secrets, SECRET_KEYS.cursorApiKey)).toBe("old"); + }); + + it("treats empty values as absent", async () => { + const { secrets } = createSecrets({ + [SECRET_KEYS.cursorApiKey.current]: "", + [SECRET_KEYS.cursorApiKey.legacy]: "", + }); + expect(await readSecret(secrets, SECRET_KEYS.cursorApiKey)).toBeUndefined(); + }); + }); + + describe("deleteSecret", () => { + it("removes both the current and legacy slot", async () => { + const { secrets, values, storage } = createSecrets({ + [SECRET_KEYS.cursorApiKey.current]: "new", + [SECRET_KEYS.cursorApiKey.legacy]: "old", + }); + + await deleteSecret(secrets, SECRET_KEYS.cursorApiKey); + + expect(values).toEqual({}); + expect(storage.delete).toHaveBeenCalledWith(SECRET_KEYS.cursorApiKey.current); + expect(storage.delete).toHaveBeenCalledWith(SECRET_KEYS.cursorApiKey.legacy); + }); + }); +}); diff --git a/test/unit/webview/agentEditorPanel.test.ts b/test/unit/webview/agentEditorPanel.test.ts new file mode 100644 index 0000000..0d7141e --- /dev/null +++ b/test/unit/webview/agentEditorPanel.test.ts @@ -0,0 +1,147 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const vscodeMock = vi.hoisted(() => ({ + created: [] as Array<{ + webview: { + html: string; + options: unknown; + postMessage: ReturnType; + asWebviewUri: (uri: unknown) => unknown; + onDidReceiveMessage: (listener: (message: unknown) => void) => { dispose: () => void }; + }; + viewType: string; + title: string; + iconPath: unknown; + reveal: ReturnType; + dispose: ReturnType; + onDidDispose: (listener: () => void) => { dispose: () => void }; + disposeListeners: Array<() => void>; + }>, +})); + +vi.mock("vscode", () => { + class WebviewPanel { + webview = { + html: "", + options: {}, + postMessage: vi.fn().mockResolvedValue(true), + asWebviewUri: (uri: unknown) => uri, + onDidReceiveMessage: (listener: (message: unknown) => void) => { + this.listeners.push(listener); + return { dispose: vi.fn() }; + }, + }; + listeners: Array<(message: unknown) => void> = []; + disposeListeners: Array<() => void> = []; + iconPath: unknown; + reveal = vi.fn(); + dispose = vi.fn(() => { + this.disposeListeners.forEach((listener) => listener()); + }); + onDidDispose = (listener: () => void) => { + this.disposeListeners.push(listener); + return { dispose: vi.fn() }; + }; + constructor( + public readonly viewType: string, + public readonly title: string, + ) { + vscodeMock.created.push(this as never); + } + } + return { + window: { + createWebviewPanel: (viewType: string, title: string) => + new WebviewPanel(viewType, title) as never, + }, + Uri: { + joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ + fsPath: [base.fsPath, ...parts].join("/"), + }), + }, + ViewColumn: { One: 1, Active: 2 }, + }; +}); + +// The panel's HTML is read from dist/gui; stub the filesystem so the +// test does not depend on a build having run. +vi.mock("node:fs", () => ({ + readFileSync: () => "{{cspSource}}{{nonce}}{{scriptUri}}{{styleUri}}{{logoUri}}{{showSettings}}", + default: { readFileSync: () => "" }, +})); + +import { AgentEditorPanel } from "../../../src/webview/agentEditorPanel"; +import type { AgentWebviewHost } from "../../../src/webview/agentWebviewHost"; + +/** AgentEditorPanel keeps a module-level singleton (one tab at a time). */ +let openPanel: AgentEditorPanel | undefined; + +afterEach(() => { + openPanel?.dispose(); + openPanel = undefined; +}); + +function createHost(): AgentWebviewHost { + return { + attach: vi.fn(), + detach: vi.fn(), + } as unknown as AgentWebviewHost; +} + +const extensionUri = { fsPath: "/ext" } as never; + +function panelFor(host: AgentWebviewHost) { + openPanel = AgentEditorPanel.createOrShow(extensionUri, host); + const created = vscodeMock.created[vscodeMock.created.length - 1]; + return { panel: openPanel, created, host }; +} + +describe("AgentEditorPanel", () => { + beforeEach(() => { + vscodeMock.created.length = 0; + }); + + it("creates an editor-area tab with the spider logo and attaches it to the host", () => { + const host = createHost(); + const { created } = panelFor(host); + + expect(vscodeMock.created).toHaveLength(1); + expect(created.viewType).toBe("spider.agentEditor"); + expect(created.title).toBe("Spider Agent"); + // Spider mark in the tab header. + expect(created.iconPath).toEqual({ fsPath: "/ext/assets/spider-icon.png" }); + expect(host.attach).toHaveBeenCalledWith(created.webview); + }); + + it("reuses the open tab instead of opening a second one", () => { + const host = createHost(); + const first = panelFor(host); + + const again = AgentEditorPanel.createOrShow(extensionUri, host); + + expect(vscodeMock.created).toHaveLength(1); + expect(again).toBe(first.panel); + expect(first.created.reveal).toHaveBeenCalled(); + }); + + it("detaches the webview from the host when the tab is closed", () => { + const host = createHost(); + const { created } = panelFor(host); + + created.dispose(); + + expect(host.detach).toHaveBeenCalledWith(created.webview); + }); + + it("reveals the open tab and reports that one exists", () => { + const host = createHost(); + const { created } = panelFor(host); + + expect(AgentEditorPanel.reveal()).toBe(true); + expect(created.reveal).toHaveBeenCalled(); + }); + + it("reports no tab when none is open", () => { + expect(AgentEditorPanel.reveal()).toBe(false); + }); +}); diff --git a/test/unit/webview/historyPanel.test.ts b/test/unit/webview/historyPanel.test.ts new file mode 100644 index 0000000..71e6825 --- /dev/null +++ b/test/unit/webview/historyPanel.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const vscodeMock = vi.hoisted(() => ({ + created: [] as Array<{ + webview: { + html: string; + options: unknown; + onDidReceiveMessage: (listener: (message: unknown) => void) => { dispose: () => void }; + postMessage: ReturnType; + }; + title: string; + reveal: ReturnType; + dispose: ReturnType; + onDidDispose: (listener: () => void) => { dispose: () => void }; + }>, +})); + +vi.mock("vscode", () => { + class WebviewPanel { + webview = { + html: "", + options: {}, + postMessage: vi.fn().mockResolvedValue(true), + asWebviewUri: (uri: unknown) => uri, + onDidReceiveMessage: (listener: (message: unknown) => void) => { + this.listeners.push(listener); + return { dispose: vi.fn() }; + }, + }; + listeners: Array<(message: unknown) => void> = []; + reveal = vi.fn(); + dispose = vi.fn(); + onDidDispose = vi.fn(() => ({ dispose: vi.fn() })); + constructor( + public readonly title: string, + public readonly viewType: string, + ) { + vscodeMock.created.push(this as never); + } + } + return { + window: { + createWebviewPanel: (viewType: string, title: string) => + new WebviewPanel(title, viewType) as never, + }, + Uri: { + joinPath: (base: { fsPath: string }, ...parts: string[]) => ({ + fsPath: [base.fsPath, ...parts].join("/"), + }), + }, + ViewColumn: { Active: 1 }, + }; +}); + +// The panel reads its HTML from dist/gui; stub the filesystem so the test does +// not depend on a build having run. +vi.mock("node:fs", () => ({ + readFileSync: () => "{{cspSource}}{{nonce}}{{scriptUri}}{{styleUri}}", + default: { readFileSync: () => "{{cspSource}}{{nonce}}{{scriptUri}}{{styleUri}}" }, +})); + +import { HistoryPanel, type HistoryPanelHost } from "../../../src/webview/historyPanel"; + +/** + * HistoryPanel keeps a module-level singleton (one tab at a time), so each test + * must tear its panel down — otherwise the next test reuses a stale instance. + */ +let openPanel: HistoryPanel | undefined; + +afterEach(() => { + openPanel?.dispose(); + openPanel = undefined; +}); + +function createHost(overrides: Partial = {}): HistoryPanelHost { + return { + listSessions: () => ({ + sessions: [ + { + sessionId: "s1", + status: "COMPLETED", + workspacePath: "/workspace", + currentTask: "Fix the bug", + updatedAt: 1, + }, + ], + activeSessionId: "s1", + }), + selectSession: vi.fn(async () => undefined), + deleteSession: vi.fn(async () => undefined), + getTranscript: vi.fn(async () => [ + { kind: "user" as const, text: "hello", timestamp: 1 }, + ]), + ...overrides, + }; +} + +const extensionUri = { fsPath: "/ext" } as never; + +function panelFor(host: HistoryPanelHost) { + openPanel = HistoryPanel.createOrShow(extensionUri, host); + const created = vscodeMock.created[vscodeMock.created.length - 1]; + const send = (message: unknown) => created.listeners.forEach((listener) => listener(message)); + return { panel: openPanel, created, send }; +} + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +describe("HistoryPanel", () => { + beforeEach(() => { + vscodeMock.created.length = 0; + }); + + it("re-broadcasts the conversation list on creation", () => { + const host = createHost(); + const { created } = panelFor(host); + + expect(created.webview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "SESSION_UPDATED", activeSessionId: "s1" }), + ); + }); + + it("delegates conversation selection to the host and refreshes the list", async () => { + const host = createHost(); + const { created, send } = panelFor(host); + + send({ type: "SELECT_SESSION", sessionId: "s2" }); + await flush(); + + expect(host.selectSession).toHaveBeenCalledWith("s2"); + expect(created.webview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "SESSION_UPDATED" }), + ); + }); + + it("delegates deletion to the host and re-broadcasts the list", async () => { + const host = createHost(); + const { created, send } = panelFor(host); + created.webview.postMessage.mockClear(); + + send({ type: "DELETE_SESSION", sessionId: "s1" }); + await flush(); + + expect(host.deleteSession).toHaveBeenCalledWith("s1"); + expect(created.webview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ type: "SESSION_UPDATED" }), + ); + }); + + it("returns the requested transcript for the preview pane", async () => { + const host = createHost(); + const { created, send } = panelFor(host); + created.webview.postMessage.mockClear(); + + send({ type: "GET_TRANSCRIPT", sessionId: "s1" }); + await flush(); + + expect(host.getTranscript).toHaveBeenCalledWith("s1"); + expect(created.webview.postMessage).toHaveBeenCalledWith({ + type: "TRANSCRIPT", + sessionId: "s1", + entries: [{ kind: "user", text: "hello", timestamp: 1 }], + }); + }); + + it("ignores malformed messages instead of touching the host", async () => { + const host = createHost(); + const { send } = panelFor(host); + + send({ type: "DELETE_SESSION" }); + send({ type: "DELETE_SESSION", sessionId: "" }); + send({ type: "GET_TRANSCRIPT", sessionId: 42 }); + send("not-an-object"); + await flush(); + + expect(host.deleteSession).not.toHaveBeenCalled(); + expect(host.getTranscript).not.toHaveBeenCalled(); + }); + + it("reveals the existing tab instead of opening a second one", () => { + const host = createHost(); + const first = panelFor(host); + + const again = HistoryPanel.createOrShow(extensionUri, host); + + expect(vscodeMock.created).toHaveLength(1); + expect(again).toBe(first.panel); + expect(first.created.reveal).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/test/unit/webview/messageRouter.openrouter.test.ts b/test/unit/webview/messageRouter.openrouter.test.ts index cc43fec..5d95413 100644 --- a/test/unit/webview/messageRouter.openrouter.test.ts +++ b/test/unit/webview/messageRouter.openrouter.test.ts @@ -57,7 +57,7 @@ describe("MessageRouter OpenRouter", () => { expect(JSON.stringify(result)).not.toContain("sk-or-test"); // Credentials are keyed by profile id; the legacy single key is migrated // away on the first write (see messageRouter.providerProfiles.test.ts). - expect(secrets.store).toHaveBeenCalledWith("codeviaCursor.provider.openrouter-default.apiKey", "sk-or-test"); + expect(secrets.store).toHaveBeenCalledWith("spider.provider.openrouter-default.apiKey", "sk-or-test"); expect(secrets.delete).toHaveBeenCalledWith("codeviaCursor.openrouter.key"); }); diff --git a/test/unit/webview/messageRouter.test.ts b/test/unit/webview/messageRouter.test.ts index 3776da3..4b60f3f 100644 --- a/test/unit/webview/messageRouter.test.ts +++ b/test/unit/webview/messageRouter.test.ts @@ -191,4 +191,17 @@ describe("MessageRouter", () => { expect(runtimeManager.resolvePermission).toHaveBeenCalledWith("req-1", "DENY"); expect(runtimeManager.retryTask).toHaveBeenCalledWith("session-1"); }); + + it("accepts OPEN_AGENT_EDITOR as a documented no-op (the host owns the editor tab)", async () => { + const agentManager = { + startTask: vi.fn(), + createSession: vi.fn(), + cancelTask: vi.fn(), + } as unknown as AgentManager; + + const router = new MessageRouter(agentManager); + const result = await router.handleMessage({ type: "OPEN_AGENT_EDITOR" }); + + expect(result).toEqual({ success: true }); + }); });