mirror of
https://github.com/PawanOsman/ChatGPT.git
synced 2026-07-18 08:05:57 +02:00
7bb5801a8c
Replace the ChatGPT reverse proxy (Docker/proxy server) entirely with OpenCursor: a Cursor-like agentic coding extension for VS Code. - Remove proxy server, Dockerfiles, docker-compose setups, and CI workflows - Add agent loop with tools (files, shell, search, web, MCP), multi-provider LLM support (OpenAI-compatible, Ollama, llama.cpp) with OAuth - Add semantic indexing, workspace context, and @-mentions - Add sidebar chat + settings webview UI (React), inline review, personas, hooks, and approval policies - Switch to pnpm, esbuild bundling, and VS Code extension packaging
974 lines
44 KiB
TypeScript
974 lines
44 KiB
TypeScript
/*
|
|
* Copyright (c) 2026 Pawan Osman <https://github.com/PawanOsman>
|
|
*
|
|
* 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;
|
|
}
|
|
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" },
|
|
];
|
|
|
|
/** Search terms per section so the nav filter finds settings inside pages too. */
|
|
const SECTION_KEYWORDS: Partial<Record<Section, string>> = {
|
|
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",
|
|
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 marketplace",
|
|
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 <div className="settings-group">{children}</div>;
|
|
}
|
|
|
|
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 (
|
|
<input
|
|
type="number"
|
|
step={step}
|
|
min={min}
|
|
max={max}
|
|
placeholder={placeholder}
|
|
value={value == null ? "" : value}
|
|
onChange={(e) => {
|
|
const t = e.target.value.trim();
|
|
onChange(t === "" ? null : Number(t));
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
|
return (
|
|
<label className="switch">
|
|
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
<span className="track" />
|
|
<span className="thumb" />
|
|
</label>
|
|
);
|
|
}
|
|
|
|
function Row({
|
|
title,
|
|
desc,
|
|
stacked,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
desc: string;
|
|
stacked?: boolean;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div className={"row" + (stacked ? " stacked" : "")}>
|
|
<div className="row-text">
|
|
<div className="row-title">{title}</div>
|
|
<div className="row-desc">{desc}</div>
|
|
</div>
|
|
{stacked ? children : <div className="row-control">{children}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---- 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 (
|
|
<div className="fc-field">
|
|
<span>{label}</span>
|
|
{values.length > 0 && (
|
|
<div className="pattern-chips">
|
|
{values.map((v) => (
|
|
<span key={v} className="pattern-chip">
|
|
<code>{v}</code>
|
|
<button className="icon-btn" title="Remove" onClick={() => onChange(values.filter((x) => x !== v))}>
|
|
<Icon name="close" size={11} />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="fc-inline-row">
|
|
<input
|
|
type="text"
|
|
value={draft}
|
|
placeholder={hint}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); add(); } }}
|
|
/>
|
|
<button className="btn-ghost sm" disabled={!draft.trim()} onClick={add}>Add</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<typeof r>) => onChange({ ...policy, [action.type]: { ...r, ...p } });
|
|
const hasLists = (r.allowlist?.length ?? 0) + (r.denylist?.length ?? 0) > 0;
|
|
return (
|
|
<div className="feature-card">
|
|
<div className="fc-head" style={{ cursor: "pointer" }} onClick={() => setOpen((v) => !v)}>
|
|
<div className="fc-title-input" style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
|
<Icon name={open ? "chevD" : "chevR"} size={14} />
|
|
<span>{action.label}</span>
|
|
{hasLists && <span className="badge-tag glob">{(r.allowlist?.length ?? 0) + (r.denylist?.length ?? 0)} rules</span>}
|
|
</div>
|
|
<select
|
|
value={r.mode}
|
|
onClick={(e) => e.stopPropagation()}
|
|
onChange={(e) => patch({ mode: e.target.value as ApprovalMode })}
|
|
className={"approval-mode " + r.mode}
|
|
>
|
|
{APPROVAL_MODES.map((m) => (
|
|
<option key={m.id} value={m.id}>{m.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{open && (
|
|
<div className="fc-body">
|
|
<p className="panel-hint" style={{ margin: 0 }}>
|
|
{action.desc} Currently: <strong>{APPROVAL_MODES.find((m) => m.id === r.mode)?.desc}</strong>.
|
|
Deny list always blocks; allow list always runs — both override the mode.
|
|
</p>
|
|
{action.listsSupported && (
|
|
<>
|
|
<PatternList label="Allow list (always run)" values={r.allowlist ?? []} hint={action.listHint} onChange={(v) => patch({ allowlist: v })} />
|
|
<PatternList label="Deny list (always block)" values={r.denylist ?? []} hint={action.listHint} onChange={(v) => patch({ denylist: v })} />
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<string, ModelUsage>;
|
|
oauthStatus: OAuthStatus;
|
|
features: FeatureConfig;
|
|
setFeatures: (p: Partial<FeatureConfig>) => 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 (
|
|
<>
|
|
<h1 className="page-title">Usage & Quota</h1>
|
|
|
|
<div className="section-label">Token Usage</div>
|
|
<div className="index-card">
|
|
<div className="index-card-title">Total</div>
|
|
<p className="row-desc">
|
|
{fmtTokens(totals.p)} input · {fmtTokens(totals.c)} output tokens across {totals.r} request{totals.r === 1 ? "" : "s"}. Tracked locally on this machine.
|
|
</p>
|
|
{rows.length === 0 ? (
|
|
<div className="empty-card" style={{ marginTop: 12 }}>No usage recorded yet. Start chatting to see per-model token usage.</div>
|
|
) : (
|
|
<div style={{ marginTop: 14 }}>
|
|
{rows.map(([model, u]) => {
|
|
const total = u.promptTokens + u.completionTokens;
|
|
return (
|
|
<div key={model} style={{ marginBottom: 12 }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 4, gap: 12 }}>
|
|
<span style={{ fontSize: 12.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{model}</span>
|
|
<span className="row-desc" style={{ flex: "0 0 auto" }}>
|
|
{fmtTokens(u.promptTokens)} in · {fmtTokens(u.completionTokens)} out · {u.requests} req
|
|
</span>
|
|
</div>
|
|
<div className="index-bar"><div className="index-bar-fill" style={{ width: `${Math.max(2, Math.round((total / max) * 100))}%` }} /></div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="index-actions">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "getUsage" })}>
|
|
<Icon name="reset" /> Refresh
|
|
</button>
|
|
<button
|
|
className="btn-secondary danger"
|
|
disabled={rows.length === 0}
|
|
onClick={() => { if (confirm("Reset all recorded token usage?")) vscode.postMessage({ type: "resetUsage" }); }}
|
|
>
|
|
<Icon name="trash" /> Reset Usage
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<Group>
|
|
<Row title="Track Usage" desc="Record per-model token usage locally. No data ever leaves your machine.">
|
|
<Toggle checked={features.trackUsage !== false} onChange={(v) => setFeatures({ trackUsage: v })} />
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Account Quota</div>
|
|
<p className="panel-hint">Rate-limit windows for your connected OAuth accounts ({oauthStatus.accounts.map((a) => OAUTH_LABEL[a.kind]).join(", ") || "none connected"}).</p>
|
|
{oauthStatus.accounts.length === 0 ? (
|
|
<div className="empty-card">
|
|
No OAuth accounts connected. Add one in the <strong>Providers → OAuth Accounts</strong> tab to see its quota here.
|
|
</div>
|
|
) : (
|
|
oauthStatus.accounts.map((a) => <OAuthAccountCard key={a.id} account={a} defaultOpen />)
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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<string[]>([]);
|
|
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 (
|
|
<div className="doc-row editing">
|
|
<div className="doc-add-row" style={{ flex: 1, margin: 0 }}>
|
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
|
|
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://…" onKeyDown={(e) => e.key === "Enter" && save()} />
|
|
<input type="number" min={1} style={{ width: 80 }} title="Max pages" value={maxPages} onChange={(e) => setMaxPages(e.target.value)} />
|
|
<button className="btn-secondary" onClick={save}><Icon name="check" /></button>
|
|
<button className="btn-secondary" onClick={() => { setEditing(false); setName(d.name); setUrl(d.url); setMaxPages(String(d.maxPages || 200)); }}><Icon name="close" /></button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
<div className="doc-row">
|
|
<span className={"doc-dot" + (d.error ? " error" : busy ? " busy" : d.indexedAt ? " ok" : "")} />
|
|
<div className="doc-info">
|
|
<div className="doc-name">{d.name}</div>
|
|
<div className={"doc-sub" + (d.error ? " error" : "")}>
|
|
{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"}
|
|
</div>
|
|
</div>
|
|
<div className="doc-actions">
|
|
<button className={"icon-btn" + (showLogs ? " active" : "")} title="Logs" onClick={() => setShowLogs((v) => !v)}><Icon name="terminal" /></button>
|
|
<button className="icon-btn" title="Edit" disabled={busy} onClick={() => setEditing(true)}><Icon name="edit" /></button>
|
|
<button className="icon-btn" title="Re-index" disabled={!!status.indexing} onClick={() => vscode.postMessage({ type: "reindexDoc", id: d.id })}><Icon name="reset" /></button>
|
|
<button className="icon-btn" title="Open docs site" onClick={() => vscode.postMessage({ type: "openExternal", url: d.url })}><Icon name="book" /></button>
|
|
<button className="icon-btn" title="Remove" disabled={busy} onClick={() => vscode.postMessage({ type: "removeDoc", id: d.id })}><Icon name="trash" /></button>
|
|
</div>
|
|
</div>
|
|
{showLogs && (
|
|
<div className="doc-logs">
|
|
{logs.length === 0 ? (
|
|
<div className="doc-logs-empty">No logs yet — logs appear while indexing (kept until the next re-index).</div>
|
|
) : (
|
|
logs.map((l, i) => (
|
|
<div key={i} className={"doc-log-line" + (/ (SKIP|FAIL|FAILED)/.test(l) ? " err" : "")}>{l}</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<>
|
|
<div className="docs-head">
|
|
<div>
|
|
<div className="section-label" style={{ marginBottom: 2 }}>Docs</div>
|
|
<div className="row-desc" style={{ margin: 0 }}>Crawl and index custom resources and developer docs</div>
|
|
</div>
|
|
<button className="btn-secondary" onClick={() => setAdding((a) => !a)}>
|
|
<Icon name="plus" /> Add Doc
|
|
</button>
|
|
</div>
|
|
<div className="index-card docs-card">
|
|
{adding && (
|
|
<div className="doc-add-row">
|
|
<input autoFocus placeholder="Name (e.g. React)" value={name} onChange={(e) => setName(e.target.value)} />
|
|
<input placeholder="https://react.dev/reference" value={url} onChange={(e) => setUrl(e.target.value)} onKeyDown={(e) => e.key === "Enter" && add()} />
|
|
<input type="number" min={1} style={{ width: 80 }} title="Max pages to crawl" value={maxPages} onChange={(e) => setMaxPages(e.target.value)} />
|
|
<button className="btn-secondary" disabled={!canAdd} onClick={add}><Icon name="plus" /> Add</button>
|
|
</div>
|
|
)}
|
|
{docs.length === 0 && !adding ? (
|
|
<p className="row-desc" style={{ padding: "10px 4px", margin: 0 }}>No docs added yet. Click "Add Doc" to crawl and index documentation from a URL.</p>
|
|
) : (
|
|
docs.map((d) => <DocRow key={d.id} d={d} status={status} />)
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** 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<FeatureConfig>) => 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));
|
|
return (
|
|
<>
|
|
<h1 className="page-title">Indexing & Docs</h1>
|
|
<div className="section-label">Codebase</div>
|
|
<div className="index-card">
|
|
<div className="index-card-title">Codebase Indexing</div>
|
|
<p className="row-desc">
|
|
Embed codebase for improved contextual understanding and knowledge. Embeddings and metadata are
|
|
stored locally on your machine — your code never leaves your computer.
|
|
</p>
|
|
<div className="index-progress">
|
|
<div className="index-progress-pct">{pct}%</div>
|
|
<div className="index-bar">
|
|
<div className="index-bar-fill" style={{ width: `${pct}%` }} />
|
|
</div>
|
|
<div className="index-progress-meta">
|
|
{status.indexing ? `Indexing ${status.done} / ${status.total} files…` : `${status.files} files`}
|
|
</div>
|
|
</div>
|
|
<div className="index-divider" />
|
|
<div className="index-model-row">
|
|
<span className="index-model-label">Embedding model</span>
|
|
<ModelSelect
|
|
models={remoteEmbed}
|
|
value={status.model}
|
|
onChange={(id) => !status.indexing && 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 }}
|
|
/>
|
|
<div className="index-actions" style={{ marginTop: 0, marginLeft: "auto" }}>
|
|
<button className="btn-secondary" disabled={status.indexing} onClick={() => vscode.postMessage({ type: "syncIndex" })}>
|
|
<Icon name="reset" /> {status.indexing ? "Syncing…" : "Sync"}
|
|
</button>
|
|
<button className="btn-secondary danger" disabled={status.indexing} onClick={() => vscode.postMessage({ type: "deleteIndex" })}>
|
|
<Icon name="trash" /> Delete Index
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="index-card rows-card">
|
|
<Row title="Index New Folders" desc="Automatically index any new folders added to the workspace">
|
|
<Toggle checked={features.indexNewFolders !== false} onChange={(v) => setFeatures({ indexNewFolders: v })} />
|
|
</Row>
|
|
<Row title="Ignore Files in .cursorignore" desc="Files to exclude from indexing in addition to .gitignore">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openCursorignore" })}>Edit</button>
|
|
</Row>
|
|
<Row title="Index Repositories for Instant Grep" desc="Automatically index repositories to speed up Grep searches. All data is stored locally.">
|
|
<Toggle checked={features.indexForGrep !== false} onChange={(v) => setFeatures({ indexForGrep: v })} />
|
|
</Row>
|
|
</div>
|
|
<DocsSection docs={docs} status={docsStatus} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function App() {
|
|
const [section, setSection] = React.useState<Section>("general");
|
|
const [s, setS] = React.useState<Settings>(DEFAULTS);
|
|
const [apiKey, setApiKey] = React.useState("");
|
|
const [models, setModels] = React.useState<string[]>([]);
|
|
const [modelList, setModelList] = React.useState<ModelDef[]>([]);
|
|
const [features, setFeaturesState] = React.useState<FeatureConfig>(EMPTY_FEATURES);
|
|
const [mcpStatus, setMcpStatus] = React.useState<McpStatus[]>([]);
|
|
const [rules, setRules] = React.useState<RuleInfo[]>([]);
|
|
const [skills, setSkills] = React.useState<SkillInfo[]>([]);
|
|
const [builtinPersonas, setBuiltinPersonas] = React.useState<Persona[]>([]);
|
|
const [modelCatalog, setModelCatalog] = React.useState<ModelDef[]>([]);
|
|
const [indexStatus, setIndexStatus] = React.useState<IndexStatus>({ indexing: false, done: 0, total: 0, files: 0, chunks: 0, model: "minilm" });
|
|
const [embedModels, setEmbedModels] = React.useState<EmbedModel[]>([]);
|
|
const [docSources, setDocSources] = React.useState<DocSourceInfo[]>([]);
|
|
const [docsStatus, setDocsStatus] = React.useState<DocsStatus>({ done: 0, total: 0 });
|
|
const [llamacppStatus, setLlamacppStatus] = React.useState<LlamacppStatus>({ installed: false, running: {}, loading: {}, errors: {}, logs: {} });
|
|
const [ollamaStatus, setOllamaStatus] = React.useState<OllamaStatus>({ installed: false, pulling: {}, errors: {} });
|
|
const [ollamaModels, setOllamaModels] = React.useState<OllamaModel[]>([]);
|
|
const [oauthStatus, setOauthStatus] = React.useState<OAuthStatus>({ accounts: [], errors: {} });
|
|
const [usage, setUsage] = React.useState<Record<string, ModelUsage>>({});
|
|
const [navQuery, setNavQuery] = React.useState("");
|
|
|
|
const set = <K extends keyof Settings>(k: K, v: Settings[K]) => setS((prev) => ({ ...prev, [k]: v }));
|
|
|
|
// Patch + persist features immediately.
|
|
const setFeatures = (patch: Partial<FeatureConfig>) => {
|
|
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 (
|
|
<div className="layout">
|
|
<aside className="sidebar">
|
|
<div className="brand">
|
|
<img className="brand-badge" src={document.getElementById("root")?.dataset.icon} alt="" />
|
|
<span>
|
|
<span className="brand-name">OpenCursor</span>
|
|
<span className="brand-sub">Local · Open Source</span>
|
|
</span>
|
|
</div>
|
|
<input
|
|
className="nav-search"
|
|
type="search"
|
|
placeholder="Search settings"
|
|
value={navQuery}
|
|
onChange={(e) => setNavQuery(e.target.value)}
|
|
/>
|
|
{navFiltered.map((n) => (
|
|
<React.Fragment key={n.id}>
|
|
{n.sep && !navQuery && <div className="nav-sep" />}
|
|
<button className={"nav-item" + (section === n.id ? " active" : "")} onClick={() => setSection(n.id)}>
|
|
<Icon name={n.icon} />
|
|
<span>{n.label}</span>
|
|
</button>
|
|
{n.id === "general" && !navQuery && (
|
|
<button className="nav-item" onClick={() => vscode.postMessage({ type: "openVsCodeSettings" })}>
|
|
<Icon name="code" />
|
|
<span>VS Code Settings ↗</span>
|
|
</button>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</aside>
|
|
|
|
<main className="content">
|
|
<div className="content-inner">
|
|
{section === "general" && (
|
|
<>
|
|
<h1 className="page-title">General</h1>
|
|
|
|
<Group>
|
|
<Row title="Providers & API Keys" desc="Manage the AI providers, API keys and OAuth accounts this extension talks to.">
|
|
<button className="btn-secondary" onClick={() => setSection("providers")}>Open</button>
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Preferences</div>
|
|
<Group>
|
|
<Row title="Editor Settings" desc="Configure font, formatting, minimap and more.">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openEditorSettings" })}>Open ↗</button>
|
|
</Row>
|
|
<Row title="Keyboard Shortcuts" desc="Configure keyboard shortcuts.">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openKeyboardShortcuts" })}>Open ↗</button>
|
|
</Row>
|
|
<Row title="VS Code Settings" desc="Open the native VS Code settings UI.">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openVsCodeSettings" })}>Open ↗</button>
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Chat</div>
|
|
<Group>
|
|
<Row title="Auto-Generate Chat Titles" desc="Generate a short AI title for new conversations after the first message.">
|
|
<Toggle checked={features.autoGenerateTitles !== false} onChange={(v) => setFeatures({ autoGenerateTitles: v })} />
|
|
</Row>
|
|
<Row title="Auto Judge Model" desc="When the chat model is set to Auto, this judge model picks the best enabled model for each task.">
|
|
<ModelSelect
|
|
models={modelList.length ? modelList : [...modelCatalog, ...(features.customModels || [])].filter((m) => 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 }}
|
|
/>
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Notifications</div>
|
|
<Group>
|
|
<Row title="System Notifications" desc="Show a notification when the agent finishes responding while the window is unfocused.">
|
|
<Toggle checked={features.notifyOnComplete !== false} onChange={(v) => setFeatures({ notifyOnComplete: v })} />
|
|
</Row>
|
|
<Row title="Completion Sound" desc="Play a sound when the agent finishes responding.">
|
|
<Toggle checked={features.completionSound === true} onChange={(v) => setFeatures({ completionSound: v })} />
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Privacy</div>
|
|
<p className="panel-hint">
|
|
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.
|
|
</p>
|
|
<Group>
|
|
<Row title="Local-First" desc="Conversations, settings and workspace context are stored on this device (VS Code global storage).">
|
|
<span className="badge-tag always">Always on</span>
|
|
</Row>
|
|
<Row title="Secure Key Storage" desc="API keys and OAuth tokens are kept in the OS secret store (VS Code SecretStorage), never in plain-text settings or synced files.">
|
|
<span className="badge-tag always">Encrypted</span>
|
|
</Row>
|
|
<Row title="No Telemetry" desc="OpenCursor collects no analytics, usage metrics or crash reports. The only outbound requests are the AI calls you trigger to your configured providers.">
|
|
<span className="badge-tag always">None</span>
|
|
</Row>
|
|
<Row title="You Choose the Destination" desc="Every request goes to the exact provider you set up — a hosted API, your own OpenAI/Anthropic-compatible endpoint, or a fully offline model via llama.cpp / Ollama.">
|
|
<span className="badge-tag">Your providers</span>
|
|
</Row>
|
|
<Row title="Open Source" desc="The full source is public and MIT-licensed, so anyone can audit exactly what the extension does with your data.">
|
|
<span className="badge-tag glob">MIT</span>
|
|
</Row>
|
|
</Group>
|
|
</>
|
|
)}
|
|
|
|
{section === "agents" && (
|
|
<>
|
|
<h1 className="page-title">Agents</h1>
|
|
|
|
<Group>
|
|
<Row title="Text Size" desc="Adjust the conversation text size.">
|
|
<select
|
|
value={features.chatTextSize || "default"}
|
|
onChange={(e) => setFeatures({ chatTextSize: e.target.value as FeatureConfig["chatTextSize"] })}
|
|
>
|
|
<option value="compact">Compact</option>
|
|
<option value="default">Default</option>
|
|
<option value="large">Large</option>
|
|
</select>
|
|
</Row>
|
|
<Row title="Submit with Ctrl + Enter" desc="When enabled, Ctrl + Enter submits chat and Enter inserts a newline.">
|
|
<Toggle checked={features.submitWithCtrlEnter === true} onChange={(v) => setFeatures({ submitWithCtrlEnter: v })} />
|
|
</Row>
|
|
<Row title="Max Tab Count" desc="Limit how many chat tabs can be open at once (0 = unlimited).">
|
|
<NumInput value={features.maxTabCount || null} step="1" min="0" placeholder="unlimited" onChange={(v) => setFeatures({ maxTabCount: v ?? 0 })} />
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Run Limits</div>
|
|
<Group>
|
|
<Row title="Max Agent Steps" desc="Pause the agent after this many steps in a single run (0 = default 50).">
|
|
<NumInput value={features.maxAgentSteps || null} step="1" min="0" placeholder="50" onChange={(v) => setFeatures({ maxAgentSteps: v ?? 0 })} />
|
|
</Row>
|
|
<Row title="Auto Continue" desc="Automatically continue when the step limit is reached instead of pausing.">
|
|
<Toggle checked={features.autoContinue === true} onChange={(v) => setFeatures({ autoContinue: v })} />
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Subagents</div>
|
|
<Group>
|
|
<Row title="Subagent Model" desc="Default model for subagents launched via the Task tool.">
|
|
<ModelSelect
|
|
models={modelList}
|
|
value={features.subagentModel}
|
|
onChange={(id) => setFeatures({ subagentModel: id })}
|
|
customItems={[{ value: "", label: "Inherit chat model", desc: "use whatever the chat uses" }]}
|
|
style={{ maxWidth: 240 }}
|
|
/>
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Context</div>
|
|
<Group>
|
|
<Row title="Web Search Tool" desc="Allow the agent to search the web for relevant information.">
|
|
<Toggle checked={features.webSearchEnabled !== false} onChange={(v) => setFeatures({ webSearchEnabled: v })} />
|
|
</Row>
|
|
<Row title="Web Fetch Tool" desc="Allow the agent to fetch content from URLs.">
|
|
<Toggle checked={features.webFetchEnabled !== false} onChange={(v) => setFeatures({ webFetchEnabled: v })} />
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Approvals & Execution</div>
|
|
<p className="panel-hint">
|
|
Tool capabilities and approval gates live in the <button className="link-btn" onClick={() => setSection("behavior")}>Behavior</button> tab.
|
|
</p>
|
|
</>
|
|
)}
|
|
|
|
{section === "usage" && (
|
|
<UsagePanel usage={usage} oauthStatus={oauthStatus} features={features} setFeatures={setFeatures} />
|
|
)}
|
|
|
|
{section === "providers" && <ProvidersPanel features={features} setFeatures={setFeatures} oauthStatus={oauthStatus} />}
|
|
|
|
{section === "models" && (
|
|
<ModelsPanel
|
|
models={models}
|
|
modelList={modelList}
|
|
features={features}
|
|
setFeatures={setFeatures}
|
|
fetchModels={fetchModels}
|
|
catalog={modelCatalog}
|
|
llamacppStatus={llamacppStatus}
|
|
ollamaModels={ollamaModels}
|
|
oauthStatus={oauthStatus}
|
|
/>
|
|
)}
|
|
|
|
{section === "llamacpp" && <LlamacppPanel features={features} status={llamacppStatus} />}
|
|
|
|
{section === "ollama" && <OllamaPanel status={ollamaStatus} models={ollamaModels} />}
|
|
|
|
{section === "personas" && <PersonasPanel features={features} setFeatures={setFeatures} builtinPersonas={builtinPersonas} />}
|
|
|
|
{section === "rules" && <RulesPanel features={features} setFeatures={setFeatures} rules={rules} skills={skills} models={models} modelList={modelList} />}
|
|
|
|
{section === "mcp" && (
|
|
<McpPanel
|
|
features={features}
|
|
setFeatures={setFeatures}
|
|
status={mcpStatus}
|
|
onSync={() => vscode.postMessage({ type: "syncMcp" })}
|
|
/>
|
|
)}
|
|
|
|
{section === "hooks" && <HooksPanel features={features} setFeatures={setFeatures} />}
|
|
|
|
{section === "behavior" && (
|
|
<>
|
|
<h1 className="page-title">Behavior</h1>
|
|
<div className="section-label">Capabilities</div>
|
|
<Group>
|
|
<Row title="Workspace Context" desc="Include workspace info in the agent's context.">
|
|
<Toggle checked={s.enableWorkspaceContext} onChange={(v) => set("enableWorkspaceContext", v)} />
|
|
</Row>
|
|
<Row title="File Reading Tools" desc="Allow reading, searching and listing files.">
|
|
<Toggle checked={s.enableFileReading} onChange={(v) => set("enableFileReading", v)} />
|
|
</Row>
|
|
<Row title="Terminal Tools" desc="Allow running terminal commands.">
|
|
<Toggle checked={s.enableTerminalSuggestions} onChange={(v) => set("enableTerminalSuggestions", v)} />
|
|
</Row>
|
|
</Group>
|
|
|
|
<div className="section-label">Approvals & Execution</div>
|
|
<p className="panel-hint">
|
|
Per-action approval policy. <strong>Allow</strong> runs silently, <strong>Auto Review</strong> only asks
|
|
for risky-looking actions (destructive commands, secrets, deletes), <strong>Ask</strong> prompts every
|
|
time, <strong>Deny</strong> always blocks. Allow/deny lists override the mode per command or file pattern.
|
|
</p>
|
|
{APPROVAL_ACTIONS.map((a) => (
|
|
<ApprovalCard
|
|
key={a.type}
|
|
action={a}
|
|
policy={{ ...DEFAULT_APPROVAL, ...(features.approvalPolicy ?? {}) }}
|
|
onChange={(p) => setFeatures({ approvalPolicy: p })}
|
|
/>
|
|
))}
|
|
<div className="panel-actions">
|
|
<button
|
|
className="btn-ghost sm"
|
|
onClick={() => setFeatures({ approvalPolicy: DEFAULT_APPROVAL })}
|
|
>
|
|
<Icon name="reset" size={13} /> Reset policy to defaults
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{section === "indexing" && (
|
|
<IndexingPanel status={indexStatus} models={embedModels} modelList={modelList} docs={docSources} docsStatus={docsStatus} features={features} setFeatures={setFeatures} />
|
|
)}
|
|
|
|
{section === "advanced" && (
|
|
<>
|
|
<h1 className="page-title">Advanced</h1>
|
|
<div className="section-label">Custom Instructions</div>
|
|
<Row title="System Prompt" desc="Prepended to the agent's system prompt for every request." stacked>
|
|
<textarea rows={6} value={s.systemPrompt} onChange={(e) => set("systemPrompt", e.target.value)} />
|
|
</Row>
|
|
</>
|
|
)}
|
|
|
|
{section === "about" && (
|
|
<>
|
|
<h1 className="page-title">About</h1>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 14, margin: "6px 0 18px" }}>
|
|
<img src={document.getElementById("root")?.dataset.icon} alt="" style={{ width: 48, height: 48, borderRadius: 10 }} />
|
|
<div>
|
|
<div style={{ fontSize: 16, fontWeight: 600 }}>OpenCursor</div>
|
|
<div className="row-desc" style={{ margin: 0 }}>AI coding agent chat inside VS Code — local, open source.</div>
|
|
</div>
|
|
</div>
|
|
<Group>
|
|
<Row title="Author" desc="Created and maintained by Pawan Osman.">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openExternal", url: "https://github.com/PawanOsman" })}>GitHub ↗</button>
|
|
</Row>
|
|
<Row title="Repository" desc="Source code, issues and contributions.">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openExternal", url: "https://github.com/PawanOsman/OpenCursor" })}>Open ↗</button>
|
|
</Row>
|
|
<Row title="Report an Issue" desc="Found a bug or have a feature request?">
|
|
<button className="btn-secondary" onClick={() => vscode.postMessage({ type: "openExternal", url: "https://github.com/PawanOsman/OpenCursor/issues" })}>Issues ↗</button>
|
|
</Row>
|
|
<Row title="License" desc="Free and open source under the MIT License.">
|
|
<span className="badge-tag glob">MIT</span>
|
|
</Row>
|
|
</Group>
|
|
<p className="panel-hint">Copyright © 2026 Pawan Osman. Licensed under the MIT License.</p>
|
|
</>
|
|
)}
|
|
|
|
</div>
|
|
</main>
|
|
|
|
<button className="btn-save" onClick={save}>
|
|
Save
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|