/* * Copyright (c) 2026 Pawan Osman * * This file is part of OpenCursor — AI coding agent chat inside VS Code. * https://github.com/PawanOsman/OpenCursor * * Licensed under the MIT License. See LICENSE file in the project root. */ import * as React from "react"; import { Icon, IconName } from "../shared/icons"; import { vscode } from "../shared/vscode"; import { ApprovalActionType, ApprovalMode, ApprovalPolicy, DEFAULT_APPROVAL, EMPTY_FEATURES, FeatureConfig, LlamacppStatus, McpStatus, ModelDef, ModelUsage, OAUTH_LABEL, OAuthStatus, OllamaModel, OllamaStatus, Persona, RuleInfo, SkillInfo } from "./features"; import { HooksPanel, LlamacppPanel, McpPanel, ModelsPanel, OAuthAccountCard, OllamaPanel, PersonasPanel, ProvidersPanel, RulesPanel } from "./FeaturePanels"; import { ModelSelect } from "../shared/ModelSelect"; interface Settings { model: string; maxResponseLength: number; enableWorkspaceContext: boolean; enableFileReading: boolean; enableTerminalSuggestions: boolean; systemPrompt: string; } const DEFAULTS: Settings = { model: "", maxResponseLength: 0, enableWorkspaceContext: true, enableFileReading: true, enableTerminalSuggestions: true, systemPrompt: "", }; type Section = "general" | "usage" | "agents" | "providers" | "models" | "llamacpp" | "ollama" | "behavior" | "personas" | "rules" | "mcp" | "hooks" | "indexing" | "advanced" | "about"; interface IndexStatus { indexing: boolean; done: number; total: number; files: number; chunks: number; model: string; backend?: "local" | "remote"; device?: string | null; accelerator?: "gpu" | "cpu" | "remote" | "pending"; deviceLabel?: string; modelRepo?: string; modelDtype?: string; modelPooling?: string; modelDim?: number; remoteBaseUrl?: string; runtime?: string; platform?: string; } interface EmbedModel { id: string; name: string; } // Ordered like Cursor's settings nav: General · Plan & Usage / Agents / Models // group · plugins-style group (Rules, MCPs, Hooks, Indexing) · misc. const NAV: { id: Section; label: string; icon: IconName; sep?: boolean }[] = [ { id: "general", label: "General", icon: "settings" }, { id: "providers", label: "Providers", icon: "globe", sep: true }, { id: "llamacpp", label: "llama.cpp", icon: "database" }, { id: "ollama", label: "Ollama", icon: "database" }, { id: "usage", label: "Usage & Quota", icon: "history", sep: true }, { id: "agents", label: "Agents", icon: "agent" }, { id: "models", label: "Models", icon: "model" }, { id: "behavior", label: "Behavior", icon: "tools" }, { id: "personas", label: "Personas", icon: "bot", sep: true }, { id: "rules", label: "Rules, Skills, Subagents", icon: "ruler" }, { id: "mcp", label: "Tools & MCPs", icon: "task" }, { id: "hooks", label: "Hooks", icon: "infinity" }, { id: "indexing", label: "Indexing & Docs", icon: "database" }, { id: "advanced", label: "Advanced", icon: "fileCode", sep: true }, { id: "about", label: "About", icon: "book" }, ]; /** Built-in tool hard timeouts (seconds). Keep in sync with src/agent/tools/shared.ts. */ const TOOL_TIMEOUT_DEFAULTS: { name: string; sec: number }[] = [ { name: "Shell", sec: 45 }, { name: "AwaitShell", sec: 60 }, { name: "Grep", sec: 20 }, { name: "Glob", sec: 20 }, { name: "FileSearch", sec: 15 }, { name: "SemanticSearch", sec: 30 }, { name: "SearchDocs", sec: 25 }, { name: "ListDir", sec: 10 }, { name: "Read", sec: 15 }, { name: "ReadLints", sec: 15 }, { name: "WebSearch", sec: 20 }, { name: "WebFetch", sec: 25 }, { name: "StrReplace", sec: 20 }, { name: "Write", sec: 20 }, { name: "Delete", sec: 10 }, { name: "EditNotebook", sec: 20 }, { name: "CallMcpTool", sec: 45 }, { name: "FetchMcpResource", sec: 30 }, { name: "ListMcpResources", sec: 15 }, { name: "TodoWrite", sec: 15 }, { name: "TodoRead", sec: 15 }, { name: "WritePlan", sec: 10 }, { name: "SwitchMode", sec: 5 }, { name: "Task", sec: 360 }, ]; /** Search terms per section so the nav filter finds settings inside pages too. */ const SECTION_KEYWORDS: Partial> = { general: "editor settings keyboard shortcuts notifications privacy chat titles auto judge model completion sound reset", usage: "tokens quota limits plan usage oauth account rate limit", agents: "text size submit ctrl enter max tab count web search fetch context conversation tool timeout shell grep", models: "enable disable model catalog reasoning effort thinking context", providers: "api key openai anthropic google openrouter oauth custom base url connect", behavior: "workspace context file reading terminal tools auto edits approval allow deny ask review policy allowlist denylist commands mcp web", personas: "persona system prompt custom", rules: "rules skills subagents", mcp: "mcp tools servers", hooks: "hooks events commands", indexing: "codebase index embedding docs semantic sync", advanced: "system prompt custom instructions", about: "about version license author github repository open source mit pawan osman", }; /** Cursor-style rounded group card wrapping settings rows. */ function Group({ children }: { children: React.ReactNode }) { return
{children}
; } function NumInput({ value, onChange, step, min, max, placeholder, }: { value: number | null; onChange: (v: number | null) => void; step?: string; min?: string; max?: string; placeholder?: string; }) { return ( { const t = e.target.value.trim(); onChange(t === "" ? null : Number(t)); }} /> ); } function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) { return ( ); } function Row({ title, desc, stacked, children, }: { title: string; desc: string; stacked?: boolean; children: React.ReactNode; }) { return (
{title}
{desc}
{stacked ? children :
{children}
}
); } // ---- Approval policy editor ---- const APPROVAL_ACTIONS: { type: ApprovalActionType; label: string; desc: string; listHint: string; listsSupported: boolean }[] = [ { type: "shell", label: "Terminal Commands", desc: "Shell commands the agent runs.", listHint: "command or prefix, e.g. git status, pnpm *, rm *", listsSupported: true }, { type: "edits", label: "File Edits", desc: "Creating and modifying files (Write, StrReplace, notebooks).", listHint: "path glob, e.g. src/**, *.md, package.json", listsSupported: true }, { type: "delete", label: "File Deletes", desc: "Deleting files.", listHint: "path glob, e.g. dist/**, *.log", listsSupported: true }, { type: "mcp", label: "MCP Tools", desc: "Tools exposed by connected MCP servers.", listHint: "tool name or prefix, e.g. mcp__github__*", listsSupported: true }, { type: "web", label: "Web Access", desc: "Web search and URL fetches.", listHint: "url or query pattern, e.g. https://github.com/*", listsSupported: true }, { type: "outside", label: "Outside Workspace", desc: "Reading or writing files outside the workspace folder.", listHint: "absolute path glob, e.g. C:/Users/me/notes/**", listsSupported: true }, ]; const APPROVAL_MODES: { id: ApprovalMode; label: string; desc: string }[] = [ { id: "allow", label: "Allow", desc: "Run without asking" }, { id: "review", label: "Auto Review", desc: "Ask only when it looks risky" }, { id: "ask", label: "Ask", desc: "Prompt every time" }, { id: "deny", label: "Deny", desc: "Always block" }, ]; /** Comma/newline-separated pattern list editor. */ function PatternList({ label, values, hint, onChange }: { label: string; values: string[]; hint: string; onChange: (v: string[]) => void }) { const [draft, setDraft] = React.useState(""); const add = () => { const items = draft.split(/[,\n]/).map((x) => x.trim()).filter(Boolean).filter((x) => !values.includes(x)); if (items.length) onChange([...values, ...items]); setDraft(""); }; return (
{label} {values.length > 0 && (
{values.map((v) => ( {v} ))}
)}
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }} />
); } function ApprovalCard({ action, policy, onChange, }: { action: (typeof APPROVAL_ACTIONS)[number]; policy: ApprovalPolicy; onChange: (p: ApprovalPolicy) => void; }) { const r = policy[action.type] ?? DEFAULT_APPROVAL[action.type]; const [open, setOpen] = React.useState(false); const patch = (p: Partial) => onChange({ ...policy, [action.type]: { ...r, ...p } }); const hasLists = (r.allowlist?.length ?? 0) + (r.denylist?.length ?? 0) > 0; return (
setOpen((v) => !v)}>
{action.label} {hasLists && {(r.allowlist?.length ?? 0) + (r.denylist?.length ?? 0)} rules}
{open && (

{action.desc} Currently: {APPROVAL_MODES.find((m) => m.id === r.mode)?.desc}. Deny list always blocks; allow list always runs — both override the mode.

{action.listsSupported && ( <> patch({ allowlist: v })} /> patch({ denylist: v })} /> )}
)}
); } function fmtTokens(n: number): string { if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"; if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"; if (n >= 1e3) return (n / 1e3).toFixed(1) + "k"; return String(n); } function UsagePanel({ usage, oauthStatus, features, setFeatures, }: { usage: Record; oauthStatus: OAuthStatus; features: FeatureConfig; setFeatures: (p: Partial) => void; }) { const rows = Object.entries(usage).sort((a, b) => b[1].lastUsed - a[1].lastUsed); const totals = rows.reduce( (t, [, u]) => ({ p: t.p + u.promptTokens, c: t.c + u.completionTokens, r: t.r + u.requests }), { p: 0, c: 0, r: 0 } ); const max = Math.max(1, ...rows.map(([, u]) => u.promptTokens + u.completionTokens)); return ( <>

Usage & Quota

Token Usage
Total

{fmtTokens(totals.p)} input · {fmtTokens(totals.c)} output tokens across {totals.r} request{totals.r === 1 ? "" : "s"}. Tracked locally on this machine.

{rows.length === 0 ? (
No usage recorded yet. Start chatting to see per-model token usage.
) : (
{rows.map(([model, u]) => { const total = u.promptTokens + u.completionTokens; return (
{model} {fmtTokens(u.promptTokens)} in · {fmtTokens(u.completionTokens)} out · {u.requests} req
); })}
)}
setFeatures({ trackUsage: v })} />
Account Quota

Rate-limit windows for your connected OAuth accounts ({oauthStatus.accounts.map((a) => OAUTH_LABEL[a.kind]).join(", ") || "none connected"}).

{oauthStatus.accounts.length === 0 ? (
No OAuth accounts connected. Add one in the Providers → OAuth Accounts tab to see its quota here.
) : ( oauthStatus.accounts.map((a) => ) )} ); } interface DocSourceInfo { id: string; name: string; url: string; pages?: number; chunks?: number; indexedAt?: number; maxPages?: number; error?: string; } interface DocsStatus { indexing?: string; done: number; total: number; error?: string; } function DocRow({ d, status }: { d: DocSourceInfo; status: DocsStatus }) { const [editing, setEditing] = React.useState(false); const [name, setName] = React.useState(d.name); const [url, setUrl] = React.useState(d.url); const [maxPages, setMaxPages] = React.useState(String(d.maxPages || 200)); const [showLogs, setShowLogs] = React.useState(false); const [logs, setLogs] = React.useState([]); const busy = status.indexing === d.id; // Pull this doc's crawl log while the panel is open (poll during indexing). React.useEffect(() => { if (!showLogs) return; const fetchLogs = () => vscode.postMessage({ type: "getDocLogs", id: d.id }); const handler = (e: MessageEvent) => { if (e.data?.type === "docLogs" && e.data.id === d.id) setLogs(e.data.lines || []); }; window.addEventListener("message", handler); fetchLogs(); const t = busy ? window.setInterval(fetchLogs, 1000) : undefined; return () => { window.removeEventListener("message", handler); if (t) window.clearInterval(t); }; }, [showLogs, busy, d.id]); const save = () => { if (!name.trim() || !/^https?:\/\//.test(url.trim())) return; vscode.postMessage({ type: "editDoc", id: d.id, name: name.trim(), url: url.trim(), maxPages: parseInt(maxPages, 10) || 200 }); setEditing(false); }; if (editing) { return (
setName(e.target.value)} placeholder="Name" /> setUrl(e.target.value)} placeholder="https://…" onKeyDown={(e) => e.key === "Enter" && save()} /> setMaxPages(e.target.value)} />
); } return ( <>
{d.name}
{busy ? `Indexing ${status.done}/${status.total} pages…` : d.error ? `Failed: ${d.error}` : d.indexedAt ? `Indexed ${new Date(d.indexedAt).toLocaleDateString()}, ${new Date(d.indexedAt).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} · ${d.pages ?? 0} pages` : "Not indexed"}
{showLogs && (
{logs.length === 0 ? (
No logs yet — logs appear while indexing (kept until the next re-index).
) : ( logs.map((l, i) => (
{l}
)) )}
)} ); } function DocsSection({ docs, status }: { docs: DocSourceInfo[]; status: DocsStatus }) { const [adding, setAdding] = React.useState(false); const [name, setName] = React.useState(""); const [url, setUrl] = React.useState(""); const [maxPages, setMaxPages] = React.useState("200"); const canAdd = name.trim() && /^https?:\/\//.test(url.trim()) && !status.indexing; const add = () => { if (!canAdd) return; vscode.postMessage({ type: "addDoc", name: name.trim(), url: url.trim(), maxPages: parseInt(maxPages, 10) || 200 }); setName(""); setUrl(""); setMaxPages("200"); setAdding(false); }; return ( <>
Docs
Crawl and index custom resources and developer docs
{adding && (
setName(e.target.value)} /> setUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} /> setMaxPages(e.target.value)} />
)} {docs.length === 0 && !adding ? (

No docs added yet. Click "Add Doc" to crawl and index documentation from a URL.

) : ( docs.map((d) => ) )}
); } /** Provider models usable for embeddings (id mentions embed/embedding). */ const isEmbeddingModel = (id: string) => /embed/i.test(id); function IndexingPanel({ status, models, modelList, docs, docsStatus, features, setFeatures, }: { status: IndexStatus; models: EmbedModel[]; modelList: ModelDef[]; docs: DocSourceInfo[]; docsStatus: DocsStatus; features: FeatureConfig; setFeatures: (p: Partial) => void; }) { const pct = status.total > 0 ? Math.round((status.done / status.total) * 100) : status.files > 0 ? 100 : 0; const remoteEmbed = modelList.filter((m) => isEmbeddingModel(m.id)); const accel = status.accelerator || "pending"; const accelClass = accel === "gpu" ? "gpu" : accel === "cpu" ? "cpu" : accel === "remote" ? "remote" : "pending"; const accelText = accel === "gpu" ? "GPU" : accel === "cpu" ? "CPU" : accel === "remote" ? "Remote" : "Loading..."; return ( <>

Indexing & Docs

Codebase
Codebase Indexing

Embed codebase for improved contextual understanding and knowledge. Embeddings and metadata are stored locally on your machine - your code never leaves your computer. The index persists across restarts; only new or changed files are re-embedded.

setFeatures({ indexingEnabled: v })} />
{pct}%
{features.indexingEnabled === false ? `Disabled - ${status.files} files on disk` : status.indexing ? `Indexing ${status.done} / ${status.total} files...` : `${status.files} files / ${status.chunks || 0} chunks`}
{accelText} {status.deviceLabel || "-"}
Model
{status.model}
{status.modelRepo && (
Repo
{status.modelRepo}
)} {status.modelDtype && (
Dtype
{status.modelDtype}
)} {status.modelPooling && (
Pooling
{status.modelPooling}
)} {status.modelDim != null && (
Dimensions
{status.modelDim}
)} {status.device != null && status.device !== "" && (
ONNX EP
{status.device}
)}
Backend
{status.backend || "local"}
{status.remoteBaseUrl && (
Endpoint
{status.remoteBaseUrl}
)}
Runtime
{status.runtime || "—"}
Platform
{status.platform || "—"}
Embedding model !status.indexing && features.indexingEnabled !== false && vscode.postMessage({ type: "setEmbedModel", modelId: id })} customItems={models.map((m) => ({ value: m.id, label: m.name, desc: "local - runs on your machine" }))} style={{ maxWidth: 260 }} />
setFeatures({ indexNewFolders: v })} disabled={features.indexingEnabled === false} /> setFeatures({ indexForGrep: v })} disabled={features.indexingEnabled === false} />
); } export function App() { const [section, setSection] = React.useState
("general"); const [s, setS] = React.useState(DEFAULTS); const [apiKey, setApiKey] = React.useState(""); const [models, setModels] = React.useState([]); const [modelList, setModelList] = React.useState([]); const [features, setFeaturesState] = React.useState(EMPTY_FEATURES); const [mcpStatus, setMcpStatus] = React.useState([]); const [rules, setRules] = React.useState([]); const [skills, setSkills] = React.useState([]); const [builtinPersonas, setBuiltinPersonas] = React.useState([]); const [modelCatalog, setModelCatalog] = React.useState([]); const [indexStatus, setIndexStatus] = React.useState({ indexing: false, done: 0, total: 0, files: 0, chunks: 0, model: "minilm" }); const [embedModels, setEmbedModels] = React.useState([]); const [docSources, setDocSources] = React.useState([]); const [docsStatus, setDocsStatus] = React.useState({ done: 0, total: 0 }); const [llamacppStatus, setLlamacppStatus] = React.useState({ installed: false, running: {}, loading: {}, errors: {}, logs: {} }); const [ollamaStatus, setOllamaStatus] = React.useState({ installed: false, pulling: {}, errors: {} }); const [ollamaModels, setOllamaModels] = React.useState([]); const [oauthStatus, setOauthStatus] = React.useState({ accounts: [], errors: {} }); const [usage, setUsage] = React.useState>({}); const [navQuery, setNavQuery] = React.useState(""); const set = (k: K, v: Settings[K]) => setS((prev) => ({ ...prev, [k]: v })); // Patch + persist features immediately. const setFeatures = (patch: Partial) => { setFeaturesState((prev) => { const next = { ...prev, ...patch }; vscode.postMessage({ type: "saveFeatures", features: next }); return next; }); }; React.useEffect(() => { const handler = (event: MessageEvent) => { const msg = event.data; if (msg.type === "loadSettings") { setS({ ...DEFAULTS, ...msg.settings }); } else if (msg.type === "navigate") { if (msg.section) setSection(msg.section as Section); } else if (msg.type === "modelsFetched") { setModels(msg.models || []); if (msg.modelList) setModelList(msg.modelList); } else if (msg.type === "features") { setFeaturesState({ ...EMPTY_FEATURES, ...msg.features }); setMcpStatus(msg.mcpStatus || []); setRules(msg.rules || []); setSkills(msg.skills || []); setBuiltinPersonas(msg.builtinPersonas || []); setModelCatalog(msg.modelCatalog || []); } else if (msg.type === "indexStatus") { setIndexStatus(msg.status); if (msg.models) setEmbedModels(msg.models); } else if (msg.type === "docSources") { setDocSources(msg.docs || []); if (msg.status) setDocsStatus(msg.status); } else if (msg.type === "docsStatus") { setDocsStatus(msg.status); // Refresh doc list when an indexing run finishes. if (!msg.status?.indexing) vscode.postMessage({ type: "getIndexStatus" }); } else if (msg.type === "llamacppStatus") { setLlamacppStatus(msg.status); } else if (msg.type === "ollamaStatus") { setOllamaStatus(msg.status); } else if (msg.type === "ollamaModels") { setOllamaModels(msg.models || []); } else if (msg.type === "oauthStatus") { setOauthStatus(msg.status); } else if (msg.type === "usageData") { setUsage(msg.usage || {}); } }; window.addEventListener("message", handler); vscode.postMessage({ type: "getSettings" }); vscode.postMessage({ type: "getFeatures" }); vscode.postMessage({ type: "getIndexStatus" }); vscode.postMessage({ type: "getUsage" }); vscode.postMessage({ type: "oauthGet" }); return () => window.removeEventListener("message", handler); }, []); // Refresh usage numbers whenever the Usage & Quota page is opened. React.useEffect(() => { if (section === "usage") vscode.postMessage({ type: "getUsage" }); }, [section]); // Fetch models across every enabled provider (grouped). Disabled providers are skipped. const fetchModels = () => vscode.postMessage({ type: "fetchAllModels" }); const save = () => { const payload: any = { type: "saveSettings", settings: s }; if (apiKey) payload.apiKey = apiKey; vscode.postMessage(payload); }; const navFiltered = navQuery.trim() ? NAV.filter((n) => { const q = navQuery.trim().toLowerCase(); return n.label.toLowerCase().includes(q) || (SECTION_KEYWORDS[n.id] || "").includes(q); }) : NAV; return (
{section === "general" && ( <>

General

Preferences
Chat
setFeatures({ autoGenerateTitles: v })} /> {/* Auto model (judge routing) hidden for now — bring back later. features.enabledModels.includes(m.id))} value={features.autoJudgeModel} onChange={(id) => setFeatures({ autoJudgeModel: id })} customItems={[{ value: "", label: "First enabled model", desc: "use the first model in the picker" }]} style={{ maxWidth: 240 }} /> */}
Notifications
setFeatures({ notifyOnComplete: v })} /> setFeatures({ completionSound: v })} />
Privacy

OpenCursor is fully local and open source. There is no OpenCursor backend - your code, keys and conversations stay on your machine and are only ever sent to the AI providers you configure yourself.

Always on Encrypted None Your providers MIT )} {section === "agents" && ( <>

Agents

setFeatures({ submitWithCtrlEnter: v })} /> setFeatures({ maxTabCount: v ?? 0 })} />
Run Limits
setFeatures({ maxAgentSteps: v ?? 0 })} /> setFeatures({ autoContinue: v })} />
Tool Timeouts

Hard timeout per tool (seconds). Empty = built-in default. Prevents a hung tool from blocking the agent forever.

{TOOL_TIMEOUT_DEFAULTS.map(({ name, sec }) => { const overrides = features.toolTimeoutsSec || {}; const val = overrides[name]; return ( 0 ? val : null} step="1" min="1" placeholder={String(sec)} onChange={(v) => { const next = { ...(features.toolTimeoutsSec || {}) }; if (v == null || v <= 0) delete next[name]; else next[name] = Math.floor(v); setFeatures({ toolTimeoutsSec: next }); }} /> ); })}
Subagents
setFeatures({ subagentModel: id })} customItems={[{ value: "", label: "Inherit chat model", desc: "use whatever the chat uses" }]} style={{ maxWidth: 240 }} />
Context
setFeatures({ webSearchEnabled: v })} /> setFeatures({ webFetchEnabled: v })} />
Approvals & Execution

Tool capabilities and approval gates live in the tab.

)} {section === "usage" && ( )} {section === "providers" && } {section === "models" && ( )} {section === "llamacpp" && } {section === "ollama" && } {section === "personas" && } {section === "rules" && } {section === "mcp" && ( vscode.postMessage({ type: "syncMcp" })} /> )} {section === "hooks" && } {section === "behavior" && ( <>

Behavior

Capabilities
set("enableWorkspaceContext", v)} /> set("enableFileReading", v)} /> set("enableTerminalSuggestions", v)} />
Approvals & Execution

Per-action approval policy. Allow runs silently, Auto Review only asks for risky-looking actions (destructive commands, secrets, deletes), Ask prompts every time, Deny always blocks. Allow/deny lists override the mode per command or file pattern.

{APPROVAL_ACTIONS.map((a) => ( setFeatures({ approvalPolicy: p })} /> ))}
)} {section === "indexing" && ( )} {section === "advanced" && ( <>

Advanced

Custom Instructions